mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 13:07:41 +00:00
Merge main into perf/contours-cover-lists
This commit is contained in:
@@ -20,6 +20,12 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
@@ -60,7 +66,7 @@ jobs:
|
||||
PY
|
||||
|
||||
release:
|
||||
needs: git-sources
|
||||
needs: [git-sources, credits]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
@@ -72,6 +78,7 @@ jobs:
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
Generated
+13
-2
@@ -2978,8 +2978,7 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
||||
[[package]]
|
||||
name = "linesweeper"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=481f66d287b2a06845f3667a19daf35f0fe958ed#481f66d287b2a06845f3667a19daf35f0fe958ed"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6265,6 +6264,7 @@ dependencies = [
|
||||
"vectorcraft-eps",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-js",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pdf",
|
||||
@@ -6283,6 +6283,7 @@ dependencies = [
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"fax",
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
@@ -6332,6 +6333,14 @@ dependencies = [
|
||||
"vectorcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-js"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"unicode-general-category",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.21.0"
|
||||
@@ -6413,8 +6422,10 @@ dependencies = [
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
|
||||
@@ -53,6 +53,7 @@ vectorcraft-metafile = { path = "crates/metafile" }
|
||||
vectorcraft-plugins = { path = "crates/plugins" }
|
||||
vectorcraft-affinity = { path = "crates/affinity" }
|
||||
vectorcraft-scriptcheck = { path = "crates/scriptcheck" }
|
||||
vectorcraft-js = { path = "crates/js" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -153,6 +154,7 @@ hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637
|
||||
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
linesweeper = { git = "https://github.com/storytold/craft-libs", rev = "481f66d287b2a06845f3667a19daf35f0fe958ed" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
@@ -20,10 +20,15 @@ list (author, source, license) is in ASSETS.md.
|
||||
(Ubuntu Font Licence 1.0), Hack (MIT), Noto Emoji (SIL Open Font License 1.1) and emoji-icon-font
|
||||
(MIT). Their license texts ship with that crate.
|
||||
|
||||
Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, linesweeper, egui, eframe, …) are
|
||||
Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, egui, eframe, …) are
|
||||
fetched through crates.io under their own permissive licenses (mostly MIT and/or Apache-2.0; also
|
||||
BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0).
|
||||
|
||||
linesweeper 0.5.0 (MIT OR Apache-2.0) is fetched from https://github.com/storytold/craft-libs at
|
||||
revision 481f66d287b2a06845f3667a19daf35f0fe958ed. The copy in craft-libs' vendor/linesweeper is
|
||||
the crates.io package with no file changed, and it keeps the package's LICENSE-MIT and
|
||||
LICENSE-APACHE.
|
||||
|
||||
JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is
|
||||
based in part on the work of the Independent JPEG Group.
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ The scores are self-assessed from our code against public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes leaves out live effects and writes symbols and live objects as their art);
|
||||
- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), leaves out other objects' effects other than geometry effects, and writes live objects as their art);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in);
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ and the work list in [`docs/gaps.md`](docs/gaps.md).
|
||||
**Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance,
|
||||
type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's
|
||||
own `.ai` files open with a wrong layer structure in real cases and are saved back with an editing copy that
|
||||
leaves out live effects and writes symbols and live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the
|
||||
keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the
|
||||
app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow
|
||||
gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print
|
||||
production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without
|
||||
|
||||
@@ -125,6 +125,13 @@ impl App {
|
||||
}
|
||||
open_files(&mut self.app, files);
|
||||
}
|
||||
let (now, zoom) = (ctx.input(|i| i.time), ctx.zoom_factor());
|
||||
if let Some(w) = &self.winit_window
|
||||
&& window::monitors_due(self.monitors_listed, now, zoom)
|
||||
{
|
||||
self.app.services.dock_monitors = window::dock_monitors(w, zoom);
|
||||
self.monitors_listed = Some((now, zoom));
|
||||
}
|
||||
self.app.tick(ctx);
|
||||
if self.pending_maximize {
|
||||
if let Some(w) = &self.winit_window {
|
||||
|
||||
@@ -298,13 +298,16 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
// up its Format menu before the sheet begins (#866); rfd shows the others.
|
||||
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
|
||||
// The dialog thread blocks here until the sheet answers.
|
||||
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
|
||||
None => {
|
||||
Ok(mac_save_format::Begin::Shown(picked)) => Box::pin(async move { picked.recv().unwrap_or_default() }),
|
||||
Ok(mac_save_format::Begin::DeferToRfd) => {
|
||||
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
|
||||
// made recent macOS versions dismiss the sheet at once (#1800).
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
// AppKit returned nil for the panel. rfd uses the same `+savePanel` and would panic
|
||||
// on that nil; treat the pick as cancelled instead (#5980).
|
||||
Err(()) => Box::pin(async move { Vec::new() }),
|
||||
},
|
||||
PickRequest::Folder => {
|
||||
let picked = dialog.pick_folder();
|
||||
|
||||
@@ -21,7 +21,7 @@ use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::rc::{Retained, Weak};
|
||||
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2::{ClassType, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView};
|
||||
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
@@ -108,21 +108,42 @@ fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) {
|
||||
panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types)));
|
||||
}
|
||||
|
||||
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the
|
||||
/// receiver with the path picked (none when canceled). `None` when the request has fewer than two
|
||||
/// named file types, or `window` has no AppKit window; nothing is shown then.
|
||||
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver<Vec<String>>> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
/// How [`begin`] decided to show a save sheet.
|
||||
#[derive(Debug)]
|
||||
pub enum Begin {
|
||||
/// The Format-menu sheet is open; wait on the receiver for the path (empty when canceled).
|
||||
Shown(Receiver<Vec<String>>),
|
||||
/// Fewer than two named file types: the caller should show an rfd panel instead.
|
||||
DeferToRfd,
|
||||
}
|
||||
|
||||
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu.
|
||||
///
|
||||
/// `Ok(Begin::Shown(…))` when the sheet is open. `Ok(Begin::DeferToRfd)` when the request has
|
||||
/// fewer than two named file types (or no AppKit window yet) so rfd should show the panel.
|
||||
/// `Err(())` when AppKit returned no panel: the caller must not call rfd, whose `+savePanel`
|
||||
/// panics on the same nil (#5980).
|
||||
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Result<Begin, ()> {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return Ok(Begin::DeferToRfd);
|
||||
};
|
||||
let filters = menu_formats(&pick.filters);
|
||||
if filters.is_empty() {
|
||||
return None;
|
||||
return Ok(Begin::DeferToRfd);
|
||||
}
|
||||
let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None };
|
||||
let Some(RawWindowHandle::AppKit(handle)) = window.window_handle().ok().map(|h| h.as_raw()) else {
|
||||
return Ok(Begin::DeferToRfd);
|
||||
};
|
||||
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
|
||||
// window is open, and winit hands out the handle only while it is.
|
||||
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
|
||||
let parent = view.window()?;
|
||||
let panel = NSSavePanel::savePanel(mtm);
|
||||
let Some(parent) = view.window() else {
|
||||
return Ok(Begin::DeferToRfd);
|
||||
};
|
||||
let Some(panel) = new_panel(mtm) else {
|
||||
log::error!("macOS save panel unavailable: AppKit returned nil for NSSavePanel (#5980)");
|
||||
return Err(());
|
||||
};
|
||||
let selected = initial_format(&pick.name, &filters);
|
||||
set_allowed_types(&panel, &filters, selected);
|
||||
if let Some(folder) = &pick.folder {
|
||||
@@ -145,7 +166,23 @@ pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver
|
||||
// The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds)
|
||||
// until the sheet closes.
|
||||
panel.beginSheetModalForWindow_completionHandler(&parent, &handler);
|
||||
Some(rx)
|
||||
Ok(Begin::Shown(rx))
|
||||
}
|
||||
|
||||
/// An `NSSavePanel` without panicking when AppKit returns nil (#5980).
|
||||
///
|
||||
/// Prefers `-[NSSavePanel init]` (Apple's current Swift API) over `+[NSSavePanel savePanel]`,
|
||||
/// which objc2 turns into a panic on nil. Falls back to the class method, still as `Option`.
|
||||
fn new_panel(mtm: MainThreadMarker) -> Option<Retained<NSSavePanel>> {
|
||||
// SAFETY: designated initializer for a bare save panel; returns nil when AppKit refuses.
|
||||
let panel: Option<Retained<NSSavePanel>> = unsafe { msg_send![NSSavePanel::alloc(mtm), init] };
|
||||
if panel.is_some() {
|
||||
return panel;
|
||||
}
|
||||
// SAFETY: class method; returns an autoreleased panel or nil. Typed as Option so nil is not a
|
||||
// panic (unlike `NSSavePanel::savePanel`).
|
||||
let panel: Option<Retained<NSSavePanel>> = unsafe { msg_send![NSSavePanel::class(), savePanel] };
|
||||
panel
|
||||
}
|
||||
|
||||
/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file.
|
||||
@@ -234,6 +271,16 @@ mod tests {
|
||||
assert_eq!(allowed_types(&filters(), 9), ["vectorcraft", "svg", "pdf"]);
|
||||
}
|
||||
|
||||
/// A nil AppKit panel must be `None`, not a panic. `NSSavePanel::savePanel` used to panic here
|
||||
/// (#5980); `new_panel` types the same message send as `Option`.
|
||||
#[test]
|
||||
fn a_null_save_panel_pointer_is_none_not_a_panic() {
|
||||
let null: *mut NSSavePanel = std::ptr::null_mut();
|
||||
// SAFETY: retaining a null object pointer is defined to yield None.
|
||||
let panel: Option<Retained<NSSavePanel>> = unsafe { Retained::retain(null) };
|
||||
assert!(panel.is_none());
|
||||
}
|
||||
|
||||
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
|
||||
#[test]
|
||||
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
|
||||
|
||||
@@ -111,6 +111,8 @@ struct App {
|
||||
pending_maximize: bool,
|
||||
/// Frames left to retry [`window::apply_pending_maximize`].
|
||||
maximize_tries: u8,
|
||||
/// The time and UI zoom of the last monitor list ([`window::monitors_due`]).
|
||||
monitors_listed: Option<(f64, f32)>,
|
||||
}
|
||||
|
||||
/// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s.
|
||||
@@ -505,6 +507,7 @@ fn main() -> std::process::ExitCode {
|
||||
winit_window,
|
||||
pending_maximize,
|
||||
maximize_tries: 8,
|
||||
monitors_listed: None,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved
|
||||
//! onto a monitor that is still connected.
|
||||
|
||||
use vectorcraft_ui_egui::DockMonitor;
|
||||
use vectorcraft_ui_egui::state::WindowGeometry;
|
||||
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
|
||||
|
||||
@@ -14,6 +15,9 @@ pub const MIN_SIZE: [f32; 2] = [800.0, 500.0];
|
||||
/// say how much of the screen they take): a monitor with less room opens it maximized.
|
||||
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
|
||||
|
||||
/// Seconds between readings of the monitor list, which picks up a monitor connected while the app runs.
|
||||
const MONITORS_EVERY: f64 = 1.0;
|
||||
|
||||
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Monitor {
|
||||
@@ -67,6 +71,15 @@ impl Monitor {
|
||||
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// This monitor for floating panels, in the points egui reports for a window on it: physical
|
||||
/// pixels divided by the monitor's scale factor and the UI zoom.
|
||||
fn docking(&self, name: Option<String>, zoom: f32) -> DockMonitor {
|
||||
let scale = self.scale * zoom;
|
||||
let [x, y] = self.pos;
|
||||
let [w, h] = self.size;
|
||||
DockMonitor { name, rect: [x, y, w, h].map(|v| v as f32 / scale), scale }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
|
||||
@@ -109,6 +122,33 @@ fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
|
||||
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
|
||||
}
|
||||
|
||||
/// Whether to list the monitors again: on the first frame, when the UI zoom changed, and at most
|
||||
/// every [`MONITORS_EVERY`] seconds while the app draws. `listed` holds the last list's time and zoom.
|
||||
pub fn monitors_due(listed: Option<(f64, f32)>, now: f64, zoom: f32) -> bool {
|
||||
listed.is_none_or(|(at, listed_zoom)| listed_zoom != zoom || now - at >= MONITORS_EVERY)
|
||||
}
|
||||
|
||||
/// The connected monitors for floating panels (`Services::dock_monitors`).
|
||||
pub fn dock_monitors(window: &winit::window::Window, zoom: f32) -> Vec<DockMonitor> {
|
||||
let monitors: Vec<_> = window.available_monitors().map(|m| (Monitor::new(m.position(), m.size(), m.scale_factor()), m.name())).collect();
|
||||
docking_list(&monitors, zoom, cfg!(target_os = "macos"))
|
||||
}
|
||||
|
||||
/// The list for floating panels, empty when `monitors` have different scales and `shared_points` is
|
||||
/// false. macOS has one desktop of points. Elsewhere a new window multiplies its saved position by the
|
||||
/// scale of the monitor it is created on, which can put a window saved on another scale off every
|
||||
/// monitor. With an empty list craft-ui opens the window on the main window's monitor.
|
||||
///
|
||||
/// A name that two monitors share is left out, so craft-ui finds those monitors by their rects: macOS
|
||||
/// can give two displays of one model the same name.
|
||||
fn docking_list(monitors: &[(Monitor, Option<String>)], zoom: f32, shared_points: bool) -> Vec<DockMonitor> {
|
||||
if !shared_points && monitors.windows(2).any(|pair| pair[0].0.scale != pair[1].0.scale) {
|
||||
return Vec::new();
|
||||
}
|
||||
let unique = |name: &String| monitors.iter().filter(|(_, other)| other.as_ref() == Some(name)).count() == 1;
|
||||
monitors.iter().map(|(monitor, name)| monitor.docking(name.clone().filter(unique), zoom)).collect()
|
||||
}
|
||||
|
||||
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
|
||||
/// geometry. On Linux the window may not be mapped yet, so [`apply_pending_maximize`] must run
|
||||
/// until the host reports maximized (fixes #3635).
|
||||
@@ -204,6 +244,51 @@ mod tests {
|
||||
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_panels_get_monitors_in_the_points_a_window_on_them_reports() {
|
||||
let dock = |m: Monitor, zoom| m.docking(Some("m".into()), zoom);
|
||||
let plain = dock(monitor(1920, 0, 1920, 1080, 1.0), 1.0);
|
||||
assert_eq!(plain, DockMonitor { name: Some("m".into()), rect: [1920.0, 0.0, 1920.0, 1080.0], scale: 1.0 });
|
||||
// A window at (3000, 200) on this monitor reports (1500, 100).
|
||||
let scaled = dock(monitor(1920, -200, 3840, 2160, 2.0), 1.0);
|
||||
assert_eq!(scaled, DockMonitor { name: Some("m".into()), rect: [960.0, -100.0, 1920.0, 1080.0], scale: 2.0 });
|
||||
// The UI zoom divides what egui reports as well.
|
||||
let zoomed = dock(monitor(-2560, 0, 2560, 1440, 1.0), 1.25);
|
||||
assert_eq!(zoomed, DockMonitor { name: Some("m".into()), rect: [-2048.0, 0.0, 2048.0, 1152.0], scale: 1.25 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitors_of_different_scales_are_listed_only_on_a_desktop_of_shared_points() {
|
||||
let named = |m: Monitor, name: &str| (m, Some(name.to_string()));
|
||||
let same = [named(monitor(0, 0, 1920, 1080, 1.5), "a"), named(monitor(1920, 0, 1920, 1080, 1.5), "b")];
|
||||
assert_eq!(docking_list(&same, 1.0, false).len(), 2);
|
||||
let mixed = [named(monitor(0, 0, 3840, 2160, 2.0), "a"), named(monitor(3840, 0, 1920, 1080, 1.0), "b")];
|
||||
assert_eq!(docking_list(&mixed, 1.0, false), Vec::new());
|
||||
assert_eq!(docking_list(&mixed, 1.0, true).len(), 2);
|
||||
assert_eq!(docking_list(&[], 1.0, false), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_two_monitors_share_is_left_out_of_the_list() {
|
||||
let list = [
|
||||
(monitor(0, 0, 1920, 1080, 1.0), Some("Monitor #1".to_string())),
|
||||
(monitor(1920, 0, 1920, 1080, 1.0), Some("Monitor #1".to_string())),
|
||||
(monitor(3840, 0, 1920, 1080, 1.0), Some("Monitor #2".to_string())),
|
||||
(monitor(5760, 0, 1920, 1080, 1.0), None),
|
||||
];
|
||||
let names: Vec<_> = docking_list(&list, 1.0, false).into_iter().map(|m| m.name).collect();
|
||||
assert_eq!(names, [None, None, Some("Monitor #2".to_string()), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_monitor_list_is_read_on_the_first_frame_then_once_a_second_or_on_a_zoom_change() {
|
||||
assert!(monitors_due(None, 0.0, 1.0));
|
||||
assert!(!monitors_due(Some((5.0, 1.0)), 5.0, 1.0));
|
||||
assert!(!monitors_due(Some((5.0, 1.0)), 5.5, 1.0));
|
||||
assert!(monitors_due(Some((5.0, 1.0)), 6.0, 1.0));
|
||||
assert!(monitors_due(Some((5.0, 1.0)), 5.5, 1.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_window_comes_back_where_it_was() {
|
||||
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
|
||||
|
||||
@@ -475,6 +475,10 @@ pub struct PatternBrush {
|
||||
pub side_frame: Option<Rect>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub side_swatch: Option<String>,
|
||||
/// Art for the corners where the path turns towards the side tile's top (`-y`); `inner_corner`
|
||||
/// is the art for the corners where it turns the other way. Both are drawn as the corner that
|
||||
/// joins a side tile on its right to one below it, and each corner tile is turned so that
|
||||
/// those two arms meet the sides.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outer_corner: Option<Node>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Rect, Vec2, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -299,6 +299,101 @@ fn pattern_places_corner_tiles() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pattern brush whose corner tiles have a 4 × 4 pt frame and, as art, a 2 × 2 pt square in the
|
||||
/// top-left quarter of the frame. Corner art joins a side tile on its right to one below it, so
|
||||
/// that quarter is on the outside of the turn. `inner` and `outer` say which kinds of corner have
|
||||
/// art; `flips` are the brush's Flip Along and Flip Across.
|
||||
fn marked_corner_brush(inner: bool, outer: bool, (flip_along, flip_across): (bool, bool)) -> Brush {
|
||||
let marker =
|
||||
Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, -2.0, 2.0, 0.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let mut b = pattern_brush(PatternFit::Stretch, false);
|
||||
if let BrushKind::Pattern(p) = &mut b.kind {
|
||||
let frame = Some(Rect::new(0.0, -2.0, 4.0, 2.0));
|
||||
if inner {
|
||||
(p.inner_corner, p.inner_corner_frame) = (Some(marker.clone()), frame);
|
||||
}
|
||||
if outer {
|
||||
(p.outer_corner, p.outer_corner_frame) = (Some(marker), frame);
|
||||
}
|
||||
(p.flip_along, p.flip_across) = (flip_along, flip_across);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Each combination of corner art (both kinds, inner only, outer only) and of the two flips.
|
||||
fn marked_corner_brushes() -> Vec<(String, Brush)> {
|
||||
let mut v = vec![];
|
||||
for (inner, outer) in [(true, true), (true, false), (false, true)] {
|
||||
for flips in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
v.push((format!("inner {inner}, outer {outer}, flips {flips:?}"), marked_corner_brush(inner, outer, flips)));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The bounds of the 2 × 2 pt corner markers among `out`, turned or not.
|
||||
fn marker_bounds(out: &[Node]) -> Vec<Rect> {
|
||||
out.iter().filter_map(Node::geometric_bounds).filter(|r| r.width() < 3.0 && r.height() < 3.0).collect()
|
||||
}
|
||||
|
||||
/// Corner tiles are square to the sides at a right angle and turn 90° with each turn of the path:
|
||||
/// on a square drawn either way round, each corner's marker fills the quarter of the band's corner
|
||||
/// outside the turn, with the brush flipped or not and with art for one kind of corner only
|
||||
/// (#6110).
|
||||
#[test]
|
||||
fn pattern_corner_tiles_are_square_to_the_sides() {
|
||||
let doc = Document::new(1.0, 1.0);
|
||||
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
|
||||
let mut reversed = BezPath::new();
|
||||
reversed.move_to((0.0, 0.0));
|
||||
for (x, y) in [(0.0, 100.0), (100.0, 100.0), (100.0, 0.0)] {
|
||||
reversed.line_to((x, y));
|
||||
}
|
||||
reversed.close_path();
|
||||
// The square turns the same way at all four corners, and the reversed square the other way,
|
||||
// so one draws only inner corner tiles and the other only outer ones.
|
||||
for (name, brush) in marked_corner_brushes() {
|
||||
for path in [&sq, &reversed] {
|
||||
let out = stroke_pieces(&doc, &brush, path, &stroke(1.0));
|
||||
let marks = marker_bounds(&out);
|
||||
assert_eq!(marks.len(), 4, "{name}: one marker a corner");
|
||||
for (corner, outside) in SQUARE_CORNERS {
|
||||
let want = Rect::from_points(corner, outside);
|
||||
assert!(
|
||||
marks
|
||||
.iter()
|
||||
.any(|r| near(r.x0, want.x0, 1e-6) && near(r.y0, want.y0, 1e-6) && near(r.x1, want.x1, 1e-6) && near(r.y1, want.y1, 1e-6)),
|
||||
"{name}: the marker at {corner:?} fills {want:?}: {marks:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// At a corner that is not a right angle, a corner tile splits the difference between the two
|
||||
/// sides: its marker lies on the line that halves the angle, outside the turn, for sharp and
|
||||
/// shallow turns either way. Near a hairpin the marker moves smoothly towards the line the path
|
||||
/// arrives on.
|
||||
#[test]
|
||||
fn pattern_corner_tiles_sit_on_the_corners_diagonal() {
|
||||
let doc = Document::new(1.0, 1.0);
|
||||
for (name, brush) in marked_corner_brushes() {
|
||||
for turn in [-179.9_f64, -170.0, -135.0, -60.0, -45.0, 45.0, 60.0, 135.0, 170.0, 179.9] {
|
||||
let (a, b) = (Vec2::new(1.0, 0.0), Vec2::new(turn.to_radians().cos(), turn.to_radians().sin()));
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.line_to((50.0, 0.0));
|
||||
bp.line_to(Point::new(50.0, 0.0) + b * 50.0);
|
||||
let out = stroke_pieces(&doc, &brush, &bp, &stroke(1.0));
|
||||
let marks = marker_bounds(&out);
|
||||
assert_eq!(marks.len(), 1, "{name}, {turn}°: one corner marker");
|
||||
let away = a - b;
|
||||
let want = Point::new(50.0, 0.0) + away / away.hypot() * 2.0_f64.sqrt();
|
||||
assert!(marks[0].center().distance(want) < 1e-6, "{name}, {turn}°: the marker is at {:?}, not {want:?}", marks[0].center());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 10 × 4 pt side tile of [`pattern_brush`] with the given corner sources and no corner art.
|
||||
fn auto_corner_brush(outer: Option<CornerSource>, inner: Option<CornerSource>) -> Brush {
|
||||
let mut b = pattern_brush(PatternFit::Stretch, false);
|
||||
|
||||
@@ -879,12 +879,16 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
|
||||
};
|
||||
trims.push(None);
|
||||
corner_half = corner_half.max(bb.width() * k / 2.0);
|
||||
let bis = a + b;
|
||||
let bis = if bis.hypot() < 1e-9 { a } else { bis / bis.hypot() };
|
||||
let xf = Affine::translate(t.verts[i].to_vec2())
|
||||
* Affine::rotate(bis.y.atan2(bis.x))
|
||||
* Affine::scale(k)
|
||||
* Affine::translate(-bb.center().to_vec2());
|
||||
// Inner and outer corner art is the corner that joins a side tile on its right to one
|
||||
// below it, so its arms point along +x and +y (as the flips leave them). The tile is
|
||||
// turned so that the line halving its arms lies on the line halving the sides at the
|
||||
// corner, back along the incoming side and on along the outgoing one: at a right angle
|
||||
// the arms meet the side tiles, and at other angles the difference is split between
|
||||
// the two sides (#6110).
|
||||
let arms = Vec2::new(if pb.flip_along { -1.0 } else { 1.0 }, if pb.flip_across { -1.0 } else { 1.0 });
|
||||
let sides = b - a;
|
||||
let angle = sides.y.atan2(sides.x) - arms.y.atan2(arms.x);
|
||||
let xf = Affine::translate(t.verts[i].to_vec2()) * Affine::rotate(angle) * Affine::scale(k) * Affine::translate(-bb.center().to_vec2());
|
||||
let mut n = art.clone();
|
||||
n.transform(xf, true);
|
||||
out.take(Some(n));
|
||||
|
||||
@@ -231,6 +231,21 @@ impl GenericCmyk {
|
||||
}
|
||||
g[p] = -(0..3).map(|i| j[i][p] * r[i]).sum::<f32>();
|
||||
}
|
||||
// An ink at a bound whose descent direction points out of the box stays where it is; the step moves the other inks.
|
||||
for p in 0..3 {
|
||||
if (x[p] <= 0.0 && g[p] < 0.0) || (x[p] >= 1.0 && g[p] > 0.0) {
|
||||
a[p] = [0.0; 3];
|
||||
for row in &mut a {
|
||||
row[p] = 0.0;
|
||||
}
|
||||
a[p][p] = 1.0;
|
||||
g[p] = 0.0;
|
||||
}
|
||||
}
|
||||
// Every ink is held at a bound, or the gradient is zero: no step can lower the error.
|
||||
if g == [0.0; 3] {
|
||||
break;
|
||||
}
|
||||
let mut improved = false;
|
||||
for _ in 0..8 {
|
||||
let mut al = a;
|
||||
|
||||
@@ -259,6 +259,46 @@ fn generic_cmyk_black_round_trips_with_bpc() {
|
||||
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
|
||||
}
|
||||
|
||||
/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few
|
||||
/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024
|
||||
/// in all) stays within 0.001 on every ink.
|
||||
#[test]
|
||||
fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() {
|
||||
let c = cms();
|
||||
let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0];
|
||||
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
|
||||
for step in -2000i32..=2000 {
|
||||
let mut rgb = pink;
|
||||
rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step));
|
||||
let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
|
||||
for i in 0..4 {
|
||||
lo[i] = lo[i].min(ink[i]);
|
||||
hi[i] = hi[i].max(ink[i]);
|
||||
}
|
||||
}
|
||||
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
|
||||
assert_eq!(lo[0], 0.0, "{lo:?}");
|
||||
}
|
||||
|
||||
/// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change
|
||||
/// by a few float steps: sRGB (0, 0, 0.25) with its blue stepped one `f32` at a time over 121 values
|
||||
/// stays within 0.001 on every ink.
|
||||
#[test]
|
||||
fn generic_cmyk_separates_nearby_colors_with_inks_at_full_alike() {
|
||||
let c = cms();
|
||||
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
|
||||
for step in -60i32..=60 {
|
||||
let blue = f32::from_bits(0.25f32.to_bits().wrapping_add_signed(step));
|
||||
let ink = c.srgb_to_cmyk([0.0, 0.0, blue], Intent::RelativeColorimetric);
|
||||
for i in 0..4 {
|
||||
lo[i] = lo[i].min(ink[i]);
|
||||
hi[i] = hi[i].max(ink[i]);
|
||||
}
|
||||
}
|
||||
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
|
||||
assert_eq!((lo[0], lo[1]), (1.0, 1.0), "{lo:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_colours_round_trip_through_the_cms() {
|
||||
let c = cms_no_bpc();
|
||||
|
||||
+337
-61
@@ -699,6 +699,34 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N
|
||||
/// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an
|
||||
/// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at
|
||||
/// the same place (by character) of both.
|
||||
/// The run of `of` at fraction `u` of its characters.
|
||||
fn run_at(of: &TextObject, u: f64) -> Option<&crate::text::TextRun> {
|
||||
let total: usize = of.runs.iter().map(|r| r.text.chars().count()).sum();
|
||||
let target = (u * total as f64) as usize;
|
||||
let mut seen = 0;
|
||||
for r in &of.runs {
|
||||
seen += r.text.chars().count();
|
||||
if seen > target {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
of.runs.last()
|
||||
}
|
||||
|
||||
/// The fraction of the characters of `runs` at the middle of each run.
|
||||
fn run_middles(runs: &[crate::text::TextRun]) -> Vec<f64> {
|
||||
let total: usize = runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
|
||||
let mut start = 0usize;
|
||||
runs.iter()
|
||||
.map(|r| {
|
||||
let len = r.text.chars().count();
|
||||
let u = (start as f64 + len as f64 / 2.0) / total as f64;
|
||||
start += len;
|
||||
u
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) {
|
||||
o.xf = lerp_affine(x.xf, y.xf, t);
|
||||
if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) =
|
||||
@@ -706,27 +734,10 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64
|
||||
{
|
||||
*f = lerp_path(fx, fy, t);
|
||||
}
|
||||
// The run of `of` at fraction `u` of its characters.
|
||||
fn run_at(of: &TextObject, u: f64) -> Option<&crate::text::TextRun> {
|
||||
let total: usize = of.runs.iter().map(|r| r.text.chars().count()).sum();
|
||||
let target = (u * total as f64) as usize;
|
||||
let mut seen = 0;
|
||||
for r in &of.runs {
|
||||
seen += r.text.chars().count();
|
||||
if seen > target {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
of.runs.last()
|
||||
}
|
||||
let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
|
||||
let mut start = 0usize;
|
||||
let tf = tc as f32;
|
||||
let mut resized = false;
|
||||
for r in &mut o.runs {
|
||||
let len = r.text.chars().count();
|
||||
let u = (start as f64 + len as f64 / 2.0) / total as f64;
|
||||
start += len;
|
||||
let middles = run_middles(&o.runs);
|
||||
for (r, u) in o.runs.iter_mut().zip(middles) {
|
||||
let (Some(rx), Some(ry)) = (run_at(x, u), run_at(y, u)) else { continue };
|
||||
let (sx, sy) = (&rx.style, &ry.style);
|
||||
r.style.fill = lerp_paint(&sx.fill, &sy.fill, tf);
|
||||
@@ -752,33 +763,115 @@ pub fn lerp_node(a: &Node, b: &Node, t: f64) -> Node {
|
||||
// Step counts
|
||||
// =====================================================================================
|
||||
|
||||
/// Largest channel difference (0..1) between the solid colours of two objects' top fill and stroke.
|
||||
fn color_distance(a: &Node, b: &Node) -> f32 {
|
||||
let mut d = 0.0f32;
|
||||
let mut cmp = |x: &Paint, y: &Paint| {
|
||||
let cols = |p: &Paint| -> Vec<Color> {
|
||||
match p {
|
||||
Paint::Solid { color, .. } => vec![*color],
|
||||
Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
};
|
||||
let (cx, cy) = (cols(x), cols(y));
|
||||
for (i, c) in cx.iter().enumerate() {
|
||||
if let Some(o) = cy.get(i).or(cy.first()) {
|
||||
let (p, q) = (c.to_rgb(), o.to_rgb());
|
||||
for k in 0..3 {
|
||||
d = d.max((p[k] - q[k]).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Largest channel difference between two solid colors, as a fraction of the channel's range: two
|
||||
/// tints of the same swatch by their tints, RGB by its 0 to 255 values over 256, CMYK and gray by
|
||||
/// their percentages, and colors in different spaces (or Lab) by the difference of their RGB
|
||||
/// values over 255. A solid paint against no paint, or anything that isn't solid, differs by 0:
|
||||
/// [`lerp_paint`] switches such paints halfway rather than mixing them.
|
||||
fn paint_difference(x: &Paint, y: &Paint) -> f64 {
|
||||
let (Paint::Solid { color: p, swatch: sp, tint: tp }, Paint::Solid { color: q, swatch: sq, tint: tq }) = (x, y) else {
|
||||
return 0.0;
|
||||
};
|
||||
cmp(&a.appearance.fill_paint(), &b.appearance.fill_paint());
|
||||
cmp(&a.appearance.stroke_paint(), &b.appearance.stroke_paint());
|
||||
d
|
||||
if sp.is_some() && sp == sq && tp != tq {
|
||||
return f64::from((tp - tq).abs());
|
||||
}
|
||||
let most = |a: &[f32], b: &[f32]| a.iter().zip(b).fold(0.0f64, |m, (u, v)| m.max(f64::from((u - v).abs())));
|
||||
match (p, q) {
|
||||
(Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => most(&[*r, *g, *b], &[*r2, *g2, *b2]) * 255.0 / 256.0,
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => most(&[*c, *m, *y, *k], &[*c2, *m2, *y2, *k2]),
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => most(&[*k], &[*k2]),
|
||||
_ => most(&p.to_rgb(), &q.to_rgb()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of intermediate steps between two keys `len` apart (along the spine).
|
||||
/// Largest difference (see [`paint_difference`]) between pairs of fills and strokes, or `None`
|
||||
/// when any of them is a gradient or a pattern.
|
||||
fn paints_difference(pairs: &[(&Paint, &Paint)]) -> Option<f64> {
|
||||
let mut d = 0.0f64;
|
||||
for (x, y) in pairs {
|
||||
if [x, y].iter().any(|p| matches!(p, Paint::Gradient(_) | Paint::Pattern { .. })) {
|
||||
return None;
|
||||
}
|
||||
d = d.max(paint_difference(x, y));
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// Largest channel difference (0..1, see [`paint_difference`]) between the solid colors of two
|
||||
/// keys: their top fill and stroke, the runs of two text objects paired as [`lerp_text`] pairs
|
||||
/// them, and the members of two groups paired as [`Lerp::new`] pairs them. `None` when any of
|
||||
/// these holds a gradient or a pattern, whose steps Smooth Color counts by distance.
|
||||
fn color_distance(a: &Node, b: &Node) -> Option<f64> {
|
||||
let mut d =
|
||||
paints_difference(&[(&a.appearance.fill_paint(), &b.appearance.fill_paint()), (&a.appearance.stroke_paint(), &b.appearance.stroke_paint())])?;
|
||||
match (&a.kind, &b.kind) {
|
||||
(NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 }) if k1 == k2 && !(*k1 && ca.len() != cb.len()) => {
|
||||
for (x, y) in ca.iter().zip(cb) {
|
||||
d = d.max(color_distance(x, y)?);
|
||||
}
|
||||
}
|
||||
(NodeKind::Text(x), NodeKind::Text(y)) => {
|
||||
// The runs `lerp_text` pairs: those of `x` and of `y` at the middle of each run of either.
|
||||
for u in run_middles(&x.runs).into_iter().chain(run_middles(&y.runs)) {
|
||||
let (Some(rx), Some(ry)) = (run_at(x, u), run_at(y, u)) else { continue };
|
||||
d = d.max(paints_difference(&[(&rx.style.fill, &ry.style.fill), (&rx.style.stroke, &ry.style.stroke)])?);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
d.is_finite().then_some(d)
|
||||
}
|
||||
|
||||
/// Smooth Color's steps between two keys by distance: the largest distance between their
|
||||
/// corresponding visible bounding-box edges, in mean key widths (left and right edges) or heights
|
||||
/// (top and bottom), rounded, less one, from 1 to 1000. Keys with no width are measured in their
|
||||
/// mean height across as well, keys with no height in their mean width, and keys with neither in
|
||||
/// points.
|
||||
fn spatial_steps(ba: Rect, bb: Rect) -> usize {
|
||||
let (w, h) = ((ba.width() + bb.width()) / 2.0, (ba.height() + bb.height()) / 2.0);
|
||||
let size = |s: f64, other: f64| {
|
||||
if s > 1e-9 {
|
||||
s
|
||||
} else if other > 1e-9 {
|
||||
other
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
};
|
||||
let (w, h) = (size(w, h), size(h, w));
|
||||
let q = ((ba.x0 - bb.x0).abs().max((ba.x1 - bb.x1).abs()) / w).max((ba.y0 - bb.y0).abs().max((ba.y1 - bb.y1).abs()) / h);
|
||||
if !q.is_finite() {
|
||||
return 1;
|
||||
}
|
||||
((q + 0.5).floor() - 1.0).clamp(1.0, 1000.0) as usize
|
||||
}
|
||||
|
||||
/// `255 × d` steps for a color difference `d` (see [`color_distance`]), a half step rounding down,
|
||||
/// from 1 to 254.
|
||||
fn level_steps(d: f64) -> f64 {
|
||||
// The tolerance keeps a difference that lands on a half step (128 of 256 RGB values gives
|
||||
// 127.5), stored as an `f32`, from rounding up.
|
||||
(255.0 * d - 0.5 - 1e-4).ceil().clamp(1.0, 254.0)
|
||||
}
|
||||
|
||||
/// Smooth Color's steps between two keys whose colors differ by `d` (see [`color_distance`]):
|
||||
/// [`level_steps`], and at most eight per point of the largest of the keys' widths and heights
|
||||
/// and the distance between their centers along either axis.
|
||||
fn color_steps(d: f64, ba: Rect, bb: Rect) -> usize {
|
||||
let by_color = level_steps(d);
|
||||
let (ca, cb) = (ba.center(), bb.center());
|
||||
let reach = [(ca.x - cb.x).abs(), (ca.y - cb.y).abs(), ba.width(), ba.height(), bb.width(), bb.height()].into_iter().fold(0.0f64, f64::max);
|
||||
let by_density = (8.0 * reach + 0.5).floor().clamp(1.0, 254.0);
|
||||
if !by_density.is_finite() {
|
||||
return by_color as usize;
|
||||
}
|
||||
by_color.min(by_density) as usize
|
||||
}
|
||||
|
||||
/// Number of intermediate steps between two keys. Specified Distance counts along the spine, `len`
|
||||
/// long between them. Smooth Color counts from the keys' colors and visible bounds, whatever the
|
||||
/// spine: keys whose colors differ make [`color_steps`] ([`level_steps`] when either key has no
|
||||
/// bounds), and others make [`spatial_steps`] (1 when either key has no bounds).
|
||||
pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> usize {
|
||||
match spacing {
|
||||
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
|
||||
@@ -787,17 +880,14 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) ->
|
||||
((len / d).round() as i64 - 1).clamp(0, 1000) as usize
|
||||
}
|
||||
BlendSpacing::SmoothColor => {
|
||||
let cd = color_distance(a, b);
|
||||
if cd > 1.0 / 255.0 {
|
||||
((cd * 255.0 / 2.0).ceil() as usize).clamp(1, 256)
|
||||
} else {
|
||||
// Same colours: base the count on the distance between the objects.
|
||||
let (ba, bb) = (a.geometric_bounds(), b.geometric_bounds());
|
||||
let dist = match (ba, bb) {
|
||||
(Some(x), Some(y)) => (x.x0 - y.x0).abs().max((x.x1 - y.x1).abs()).max((x.y0 - y.y0).abs()).max((x.y1 - y.y1).abs()),
|
||||
_ => len,
|
||||
};
|
||||
((dist / 2.0).ceil() as usize).clamp(1, 256)
|
||||
let bounds = a.visual_bounds().zip(b.visual_bounds());
|
||||
match (color_distance(a, b), bounds) {
|
||||
// Colors that differ by less than a thousandth of a step count as the same.
|
||||
(Some(d), Some((ba, bb))) if d * 255.0 > 1e-3 => color_steps(d, ba, bb),
|
||||
(Some(d), None) if d * 255.0 > 1e-3 => level_steps(d) as usize,
|
||||
// Same colors, gradients and patterns: the distance between the objects.
|
||||
(_, Some((ba, bb))) => spatial_steps(ba, bb),
|
||||
(_, None) => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1148,13 +1238,19 @@ fn requested_steps(keys: &[Arc<Node>], spec: &BlendSpec, rail: Option<&Rail>) ->
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Most steps `spacing` makes between two keys, whatever their size and distance
|
||||
/// ([`blend_step_count`]'s clamps).
|
||||
fn most_steps(spacing: BlendSpacing) -> usize {
|
||||
/// Most steps `spacing` makes between keys `a` and `b`, whatever their size and distance
|
||||
/// ([`blend_step_count`]'s clamps). A copy of a blend is moved and scaled, never recolored
|
||||
/// ([`Shape::Other`]), so Smooth Color between colors that differ makes no more than their color
|
||||
/// change asks for, whatever the copy's scale or rotation. By distance, a copy turned by Align to
|
||||
/// Path can measure its edges differently, so it counts as up to 1000.
|
||||
fn most_steps(a: &Node, b: &Node, spacing: BlendSpacing) -> usize {
|
||||
match spacing {
|
||||
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
|
||||
BlendSpacing::Distance(_) => 1000,
|
||||
BlendSpacing::SmoothColor => 256,
|
||||
BlendSpacing::SmoothColor => match color_distance(a, b) {
|
||||
Some(d) if d * 255.0 > 1e-3 => level_steps(d) as usize,
|
||||
_ => 1000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1182,8 +1278,8 @@ struct StepCount {
|
||||
impl StepCount {
|
||||
/// Steps the blends in `n` make in all when drawn, each keeping to [`MAX_BLEND_STEPS`].
|
||||
/// `copy`: `n` is part of a blend's step, a copy moved and scaled into place. A blend spaced
|
||||
/// by distance (or by distance where the colours match) makes more steps scaled up, so a
|
||||
/// copy counts the most its spacing can make between each pair, an upper bound for any scale.
|
||||
/// by distance, or by Smooth Color, can make more steps scaled up, so a copy counts the most
|
||||
/// its spacing can make between each pair ([`most_steps`]), an upper bound for any scale.
|
||||
fn node(&mut self, n: &Node, copy: bool) -> usize {
|
||||
let at = (std::ptr::from_ref(n) as usize, copy);
|
||||
if let Some(&known) = self.known.get(&at) {
|
||||
@@ -1249,7 +1345,11 @@ impl StepCount {
|
||||
fn most(&mut self, keys: &[Arc<Node>], spec: &BlendSpec) -> usize {
|
||||
let inside = self.nodes(keys, true);
|
||||
let each = self.steps(keys);
|
||||
let demand = each.iter().map(|&c| c.saturating_mul(most_steps(spec.spacing))).fold(0, usize::saturating_add);
|
||||
let most = keys.windows(2).map(|w| match w {
|
||||
[a, b] => most_steps(a, b, spec.spacing),
|
||||
_ => 0,
|
||||
});
|
||||
let demand = each.iter().zip(most).map(|(&c, m)| c.saturating_mul(m)).fold(0, usize::saturating_add);
|
||||
inside.saturating_add(demand.min(MAX_BLEND_STEPS.saturating_sub(inside)))
|
||||
}
|
||||
}
|
||||
@@ -1634,4 +1734,180 @@ mod tests {
|
||||
assert_eq!(blend_steps(&keys[..50], &spec), vec![1000; 49]);
|
||||
assert_eq!(fit_steps(&[10, 20], &[1, 1], MAX_BLEND_STEPS), vec![0, 0]);
|
||||
}
|
||||
|
||||
/// A key for the Smooth Color tests: a rectangle of `size` (width, height) centered on `c`,
|
||||
/// with `fill`, and `stroke` at `width`.
|
||||
fn smooth_key(c: (f64, f64), size: (f64, f64), fill: Paint, stroke: Paint, width: f64) -> Node {
|
||||
let ((cx, cy), (w, h)) = (c, size);
|
||||
let rect = Rect::new(cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0);
|
||||
Node::path(NodeId(1), shapes::rectangle(rect), Appearance::basic(fill, stroke, width))
|
||||
}
|
||||
|
||||
/// Smooth Color's steps between two filled `size` rectangles centered on `a` and `b`.
|
||||
fn smooth(a: (f64, f64), b: (f64, f64), size: (f64, f64), fill_a: Color, fill_b: Color) -> usize {
|
||||
let key = |c, fill| smooth_key(c, size, Paint::solid(fill), Paint::None, 0.0);
|
||||
blend_step_count(&key(a, fill_a), &key(b, fill_b), BlendSpacing::SmoothColor, a.0.max(b.0) - a.0.min(b.0))
|
||||
}
|
||||
|
||||
/// Smooth Color counts 255 steps for a full change of the channel that changes most, a half
|
||||
/// step rounding down: RGB values over 256, CMYK and gray in percent, a spot color by its
|
||||
/// tint, colors in different spaces by their RGB values over 255, a fill or a stroke,
|
||||
/// whichever changes more.
|
||||
#[test]
|
||||
fn smooth_color_counts_a_step_per_level_of_color_change() {
|
||||
let (a, b, size) = ((50.0, 150.0), (250.0, 150.0), (20.0, 20.0));
|
||||
let black = Color::rgb8(0, 0, 0);
|
||||
for (level, steps) in [(1, 1), (2, 2), (5, 5), (64, 64), (127, 127), (128, 127), (129, 128), (200, 199), (254, 253), (255, 254)] {
|
||||
assert_eq!(smooth(a, b, size, black, Color::rgb8(level, 0, 0)), steps, "black to red {level}");
|
||||
assert_eq!(smooth(a, b, size, black, Color::rgb8(level, level, level)), steps, "black to gray {level}");
|
||||
}
|
||||
assert_eq!(smooth(a, b, size, Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)), 254);
|
||||
assert_eq!(smooth(a, b, size, black, Color::rgb8(64, 128, 0)), 127, "the channel that changes most");
|
||||
assert_eq!(smooth(a, b, size, Color::rgb8(17, 42, 93), Color::rgb8(91, 147, 220)), 127);
|
||||
for (percent, steps) in [(0.2, 1), (1.0, 3), (5.0, 13), (10.0, 25), (25.0, 64), (50.0, 127), (100.0, 254)] {
|
||||
let p = percent / 100.0;
|
||||
assert_eq!(smooth(a, b, size, Color::cmyk(0.0, 0.0, 0.0, 0.0), Color::cmyk(p, 0.0, 0.0, 0.0)), steps, "cyan {percent}%");
|
||||
assert_eq!(smooth(a, b, size, Color::gray(0.0), Color::gray(p)), steps, "gray {percent}%");
|
||||
}
|
||||
let spot = |tint: f32| Paint::Solid { color: Color::rgb8(255, 0, 0).tinted(tint), swatch: Some("Spot".into()), tint };
|
||||
let key = |c, fill| smooth_key(c, size, fill, Paint::None, 0.0);
|
||||
assert_eq!(blend_step_count(&key(a, spot(1.0)), &key(b, spot(0.5)), BlendSpacing::SmoothColor, 200.0), 127, "a tint of 50%");
|
||||
let stroked = |c, fill: Color, stroke: Color| smooth_key(c, size, Paint::solid(fill), Paint::solid(stroke), 1.0);
|
||||
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
|
||||
assert_eq!(blend_step_count(&stroked(a, red, black), &stroked(b, red, blue), BlendSpacing::SmoothColor, 200.0), 254, "the stroke changes");
|
||||
let outline = |c, stroke: Color| smooth_key(c, size, Paint::None, Paint::solid(stroke), 1.0);
|
||||
assert_eq!(blend_step_count(&outline(a, red), &outline(b, blue), BlendSpacing::SmoothColor, 200.0), 254, "strokes alone");
|
||||
assert_eq!(smooth(a, b, size, Color::rgb8(255, 255, 127), Color::cmyk(0.0, 0.0, 0.0, 0.0)), 128, "RGB against CMYK white");
|
||||
assert_eq!(smooth(a, b, size, Color::rgb8(255, 255, 127), Color::rgb8(255, 255, 255)), 127, "RGB against RGB white");
|
||||
}
|
||||
|
||||
/// Smooth Color reads the colors of group members paired in order and of text runs, and
|
||||
/// counts a solid paint against no paint by distance, since such a paint switches halfway
|
||||
/// instead of mixing.
|
||||
#[test]
|
||||
fn smooth_color_reads_the_colors_inside_groups_and_text() {
|
||||
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
|
||||
let square = |x: f64, c: Color| Arc::new(smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0));
|
||||
let group = |x: f64, top: Color| Node::new(NodeId(1), NodeKind::Group { children: vec![square(x, red), square(x, top)], clip: false });
|
||||
let steps = |a: &Node, b: &Node| blend_step_count(a, b, BlendSpacing::SmoothColor, 200.0);
|
||||
assert_eq!(steps(&group(50.0, red), &group(250.0, blue)), 254, "groups whose members change color");
|
||||
assert_eq!(steps(&group(50.0, red), &group(250.0, red)), 9, "groups of the same colors");
|
||||
let text = |x: f64, c: Color| {
|
||||
let style = crate::CharStyle { fill: Paint::solid(c), ..Default::default() };
|
||||
Node::new(NodeId(1), NodeKind::Text(Box::new(crate::TextObject::point(Point::new(x, 150.0), "Hi", style))))
|
||||
};
|
||||
assert_eq!(steps(&text(50.0, red), &text(250.0, blue)), 254, "text whose runs change color");
|
||||
let mut two_runs = text(250.0, red);
|
||||
if let NodeKind::Text(t) = &mut two_runs.kind {
|
||||
let style = crate::CharStyle { fill: Paint::solid(blue), ..t.runs[0].style.clone() };
|
||||
t.runs.push(crate::text::TextRun::new("Hi", style));
|
||||
}
|
||||
assert_eq!(steps(&text(50.0, red), &two_runs), 254, "a run that turns blue against one red run");
|
||||
let empty = |x: f64| smooth_key((x, 150.0), (20.0, 20.0), Paint::None, Paint::None, 0.0);
|
||||
assert_eq!(steps(&smooth_key((50.0, 150.0), (20.0, 20.0), Paint::solid(red), Paint::None, 0.0), &empty(250.0)), 9, "a fill against none");
|
||||
let nothing = |c: Color| Node::path(NodeId(1), PathData::new(vec![]), Appearance::basic(Paint::solid(c), Paint::None, 0.0));
|
||||
assert_eq!(steps(¬hing(red), ¬hing(blue)), 254, "keys without bounds whose colors differ");
|
||||
assert_eq!(steps(¬hing(red), ¬hing(red)), 1, "keys without bounds of the same color");
|
||||
}
|
||||
|
||||
/// Smooth Color makes at most eight steps per point of the largest of the keys' widths and
|
||||
/// heights and the distance between their centers along either axis.
|
||||
#[test]
|
||||
fn smooth_color_makes_at_most_eight_steps_a_point() {
|
||||
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
|
||||
for (dx, steps) in [(0.0, 160), (20.0, 160), (20.1, 161), (25.0, 200), (31.5, 252), (40.0, 254)] {
|
||||
assert_eq!(smooth((50.0, 150.0), (50.0 + dx, 150.0), (20.0, 20.0), red, blue), steps, "{dx} pt apart");
|
||||
}
|
||||
assert_eq!(smooth((100.0, 150.0), (100.0, 175.0), (20.0, 20.0), red, blue), 200, "25 pt apart vertically");
|
||||
assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (5.0, 5.0), red, blue), 40, "5 pt squares on each other");
|
||||
assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (25.0, 5.0), red, blue), 200, "the longer side counts");
|
||||
assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (1.0, 1.0), red, blue), 8);
|
||||
}
|
||||
|
||||
/// Smooth Color between keys of the same colors counts the largest distance between their
|
||||
/// corresponding visible edges in mean key widths or heights, rounded, less one; so do keys
|
||||
/// with gradients, whatever their colors.
|
||||
#[test]
|
||||
fn smooth_color_between_the_same_colors_counts_by_distance() {
|
||||
let red = Color::rgb8(255, 0, 0);
|
||||
let same = |a, b, size| smooth(a, b, size, red, red);
|
||||
for (dx, steps) in [(25.0, 1), (50.0, 2), (100.0, 4), (200.0, 9), (400.0, 19), (800.0, 39)] {
|
||||
assert_eq!(same((50.0, 150.0), (50.0 + dx, 150.0), (20.0, 20.0)), steps, "20 pt squares {dx} pt apart");
|
||||
}
|
||||
for (side, steps) in [(5.0, 39), (10.0, 19), (40.0, 4), (80.0, 2)] {
|
||||
assert_eq!(same((50.0, 150.0), (250.0, 150.0), (side, side)), steps, "{side} pt squares");
|
||||
}
|
||||
assert_eq!(same((50.0, 150.0), (250.0, 150.0), (80.0, 10.0)), 2, "wide");
|
||||
assert_eq!(same((50.0, 150.0), (250.0, 150.0), (10.0, 80.0)), 19, "tall");
|
||||
assert_eq!(same((50.0, 250.0), (250.0, 50.0), (20.0, 20.0)), 9, "diagonal");
|
||||
assert_eq!(same((50.0, 250.0), (250.0, 50.0), (80.0, 10.0)), 19, "wide and diagonal");
|
||||
assert_eq!(same((50.0, 150.0), (1050.0, 150.0), (1.0, 1.0)), 999);
|
||||
assert_eq!(same((50.0, 150.0), (1052.0, 150.0), (1.0, 1.0)), 1000, "at most 1000");
|
||||
assert_eq!(same((50.0, 150.0), (50.0, 150.0), (20.0, 20.0)), 1, "at least one");
|
||||
assert_eq!(same((50.0, 150.0), (250.0, 150.0), (0.0, 20.0)), 9, "vertical lines, measured in their height");
|
||||
assert_eq!(same((50.0, 150.0), (50.0, 350.0), (20.0, 0.0)), 9, "horizontal lines, measured in their width");
|
||||
assert_eq!(same((50.0, 150.0), (60.0, 150.0), (0.0, 0.0)), 9, "points, measured in points");
|
||||
let key = |c, size, stroke: f64| smooth_key(c, size, Paint::solid(red), Paint::solid(Color::BLACK), stroke);
|
||||
let wide =
|
||||
blend_step_count(&key((50.0, 150.0), (10.0, 20.0), 0.0), &key((250.0, 150.0), (80.0, 20.0), 0.0), BlendSpacing::SmoothColor, 200.0);
|
||||
assert_eq!(wide, 4, "keys of different widths");
|
||||
let strokes =
|
||||
blend_step_count(&key((50.0, 150.0), (20.0, 20.0), 1.0), &key((250.0, 150.0), (20.0, 20.0), 10.0), BlendSpacing::SmoothColor, 200.0);
|
||||
assert_eq!(strokes, 7, "strokes widen the visible edges");
|
||||
let gradient = |a: Color, b: Color| {
|
||||
Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
|
||||
Default::default(),
|
||||
vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)],
|
||||
))))
|
||||
};
|
||||
let shaded = |c, fill| smooth_key(c, (20.0, 20.0), fill, Paint::None, 0.0);
|
||||
let blue = Color::rgb8(0, 0, 255);
|
||||
for (from, to) in [(gradient(red, blue), gradient(red, blue)), (gradient(red, blue), gradient(blue, red))] {
|
||||
assert_eq!(blend_step_count(&shaded((50.0, 150.0), from), &shaded((250.0, 150.0), to), BlendSpacing::SmoothColor, 200.0), 9);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Smooth Color blend counts each pair of keys on its own.
|
||||
#[test]
|
||||
fn smooth_color_counts_each_pair_of_keys() {
|
||||
let key = |x: f64, c: Color| Arc::new(smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0));
|
||||
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
|
||||
let spec = BlendSpec::default();
|
||||
assert_eq!(blend_steps(&[key(50.0, red), key(150.0, red), key(250.0, blue)], &spec), vec![4, 254]);
|
||||
assert_eq!(blend_steps(&[key(50.0, red), key(150.0, blue), key(250.0, red)], &spec), vec![254, 254]);
|
||||
}
|
||||
|
||||
/// A Smooth Color blend between different colors inside another blend's keys counts as the
|
||||
/// steps its colors ask for, so the outer blend keeps the steps it asks for within
|
||||
/// [`MAX_BLEND_STEPS`]: 200 steps, each a copy making 254 more. The copies keep their keys'
|
||||
/// colors, also halfway between a red-to-blue and a blue-to-red blend.
|
||||
#[test]
|
||||
fn a_smooth_color_blend_in_a_key_counts_its_color_steps() {
|
||||
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
|
||||
let key = |x: f64, c: Color| smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0);
|
||||
let inner = |x: f64, from: Color, to: Color| blend_of(key(x, from), key(x + 100.0, to), BlendSpacing::SmoothColor);
|
||||
let outer = blend_of(inner(0.0, red, blue), inner(500.0, red, blue), BlendSpacing::Steps(200));
|
||||
let (keys, spec) = blend_parts(&outer).unwrap();
|
||||
assert_eq!(blend_steps(keys, spec), vec![200]);
|
||||
let crossed = blend_of(inner(0.0, red, blue), inner(500.0, blue, red), BlendSpacing::Steps(199));
|
||||
let (keys, spec) = blend_parts(&crossed).unwrap();
|
||||
assert_eq!(blend_steps(keys, spec), vec![199]);
|
||||
let drawn = blend_expand(keys, spec);
|
||||
let Some(NodeKind::Blend { children, .. }) = drawn.get(100).map(|n| &n.kind) else { panic!("a copy of a blend halfway") };
|
||||
let fills: Vec<_> = children.iter().map(|k| k.appearance.fill_paint().color()).collect();
|
||||
assert!(fills == [Some(red), Some(blue)] || fills == [Some(blue), Some(red)], "{fills:?}");
|
||||
}
|
||||
|
||||
/// A Smooth Color blend between the same colors inside another blend's keys counts as up to
|
||||
/// 1000 steps, which a copy turned to a path can ask for: 200 steps of copies of it come
|
||||
/// down to 99.
|
||||
#[test]
|
||||
fn a_smooth_color_blend_of_the_same_colors_in_a_key_counts_up_to_1000_steps() {
|
||||
let red = Color::rgb8(255, 0, 0);
|
||||
let key = |x: f64| smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(red), Paint::None, 0.0);
|
||||
let inner = |x: f64| blend_of(key(x), key(x + 100.0), BlendSpacing::SmoothColor);
|
||||
let outer = blend_of(inner(0.0), inner(500.0), BlendSpacing::Steps(200));
|
||||
let (keys, spec) = blend_parts(&outer).unwrap();
|
||||
assert_eq!(blend_steps(keys, spec), vec![99]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +352,7 @@ impl Document {
|
||||
}
|
||||
false
|
||||
}
|
||||
crate::id_index::note_tree_walk();
|
||||
let mut p = vec![];
|
||||
find(&self.layers, id, &mut p).then_some(p)
|
||||
}
|
||||
@@ -393,6 +394,7 @@ impl Document {
|
||||
});
|
||||
}
|
||||
if !want.is_empty() {
|
||||
crate::id_index::note_tree_walk();
|
||||
let mut rest = HashMap::with_capacity(want.len());
|
||||
walk(&self.layers, &want, &mut Vec::new(), &mut rest);
|
||||
out.extend(rest);
|
||||
|
||||
@@ -348,11 +348,14 @@ impl Document {
|
||||
t.cached_bounds = None;
|
||||
t.cached_baselines.clear();
|
||||
}
|
||||
let on_path = n.extras().objects_on_path.is_some();
|
||||
let mut renamed = std::collections::HashMap::new();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old: Vec<Arc<Node>> = std::mem::take(ch);
|
||||
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||||
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
|
||||
if on_path {
|
||||
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
|
||||
}
|
||||
}
|
||||
// An Objects on Path group names its path and its objects by id: the copy names the copies.
|
||||
if let Some(e) = n.extras_if_any()
|
||||
|
||||
@@ -90,6 +90,24 @@ impl Paths {
|
||||
/// object and look the next one up, again and again).
|
||||
const MISSES_PER_REBUILD: u32 = 4;
|
||||
|
||||
thread_local! {
|
||||
/// [`tree_walks`] of this thread.
|
||||
static TREE_WALKS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many times lookups by id on this thread have walked a document's tree, reading up to all of
|
||||
/// its nodes: one count per search of the tree and per rebuild of the cache. An edit that looks its
|
||||
/// objects up one at a time counts about one walk per object; one that looks them all up at once
|
||||
/// counts a few.
|
||||
pub fn tree_walks() -> u64 {
|
||||
TREE_WALKS.get()
|
||||
}
|
||||
|
||||
/// Adds one to [`tree_walks`].
|
||||
pub(crate) fn note_tree_walk() {
|
||||
TREE_WALKS.set(TREE_WALKS.get().saturating_add(1));
|
||||
}
|
||||
|
||||
/// The cache, shared by clones of a document (each checks every path it uses against its own tree).
|
||||
#[derive(Default)]
|
||||
pub struct IdIndex(RwLock<Option<Arc<Paths>>>, std::sync::atomic::AtomicU32);
|
||||
@@ -125,6 +143,7 @@ impl IdIndex {
|
||||
}
|
||||
}
|
||||
}
|
||||
note_tree_walk();
|
||||
let mut out = Paths::default();
|
||||
walk(layers, u32::MAX, &mut out);
|
||||
let out = Arc::new(out);
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod graph;
|
||||
mod halftone_radii;
|
||||
pub mod hit;
|
||||
mod id_index;
|
||||
pub use id_index::tree_walks;
|
||||
pub mod inks;
|
||||
mod inline;
|
||||
pub mod intertwine;
|
||||
|
||||
@@ -415,6 +415,68 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::update_links`] for the links `pick` holds for only, in one pass; only the nodes on the
|
||||
/// way to one are copied.
|
||||
pub fn update_links_where(&mut self, pick: impl Fn(&LinkInfo) -> bool, mut f: impl FnMut(&mut LinkInfo)) {
|
||||
fn link_of(n: &Node) -> Option<&LinkInfo> {
|
||||
match &n.kind {
|
||||
NodeKind::Image(image) => image.link.as_ref(),
|
||||
NodeKind::PlacedDocument(placed) => Some(&placed.link),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `n` updated in place, a copy of it when shared; left as it is when it holds no picked link.
|
||||
fn update(n: &mut Arc<Node>, pick: &dyn Fn(&LinkInfo) -> bool, f: &mut dyn FnMut(&mut LinkInfo)) {
|
||||
// Each child is updated through a second handle, so an updated child is a new node and a new
|
||||
// pointer marks a change.
|
||||
let mask = n.extras().mask.as_ref().and_then(|m| {
|
||||
let mut art = Arc::clone(&m.art);
|
||||
update(&mut art, pick, f);
|
||||
(!Arc::ptr_eq(&art, &m.art)).then_some(art)
|
||||
});
|
||||
let mut children = vec![];
|
||||
for (i, c) in n.children().into_iter().flatten().enumerate() {
|
||||
let mut new = Arc::clone(c);
|
||||
update(&mut new, pick, f);
|
||||
if !Arc::ptr_eq(&new, c) {
|
||||
children.push((i, new));
|
||||
}
|
||||
}
|
||||
let own = link_of(n).is_some_and(pick);
|
||||
if !own && mask.is_none() && children.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(n);
|
||||
let link = match &mut n.kind {
|
||||
NodeKind::Image(image) => image.link.as_mut(),
|
||||
NodeKind::PlacedDocument(placed) => Some(&mut placed.link),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(link) = link.filter(|_| own) {
|
||||
f(link);
|
||||
}
|
||||
if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) {
|
||||
m.art = art;
|
||||
}
|
||||
if let Some(slots) = n.children_mut() {
|
||||
for (i, c) in children {
|
||||
if let Some(slot) = slots.get_mut(i) {
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let roots = self
|
||||
.layers
|
||||
.iter_mut()
|
||||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||||
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
|
||||
for root in roots {
|
||||
update(root, &pick, &mut f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask
|
||||
/// art included) for which `pick` holds; only the subtrees holding one are copied.
|
||||
pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) {
|
||||
@@ -486,6 +548,40 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `update_links_where` changes only the picked links, in a copy of the nodes on the way to them;
|
||||
/// the other nodes stay shared with the document it was cloned from.
|
||||
#[test]
|
||||
fn update_links_where_copies_only_the_nodes_it_changes() {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
image(&mut d, "a", Some(LinkInfo::new("/art/a.png")));
|
||||
image(&mut d, "b", Some(LinkInfo::new("/art/b.png")));
|
||||
image(&mut d, "c", None);
|
||||
let mut copy = d.clone();
|
||||
let mut calls = 0;
|
||||
copy.update_links_where(
|
||||
|l| l.path == "/art/a.png",
|
||||
|l| {
|
||||
calls += 1;
|
||||
l.relative = Some("art/a.png".into());
|
||||
},
|
||||
);
|
||||
assert_eq!(calls, 1);
|
||||
let node = |d: &Document, key: &str| {
|
||||
d.layers[0].children().unwrap().iter().find(|n| matches!(&n.kind, NodeKind::Image(im) if im.key == key)).unwrap().clone()
|
||||
};
|
||||
let relative = |n: &Node| match &n.kind {
|
||||
NodeKind::Image(im) => im.link.as_ref().and_then(|l| l.relative.clone()),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!((relative(&node(©, "a")), relative(&node(&d, "a"))), (Some("art/a.png".into()), None));
|
||||
assert!(Arc::ptr_eq(&node(©, "b"), &node(&d, "b")) && Arc::ptr_eq(&node(©, "c"), &node(&d, "c")));
|
||||
assert!(!Arc::ptr_eq(©.layers[0], &d.layers[0]));
|
||||
// Nothing picked: nothing is copied.
|
||||
let mut same = d.clone();
|
||||
same.update_links_where(|_| false, |_| {});
|
||||
assert!(Arc::ptr_eq(&same.layers[0], &d.layers[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_placeholder_is_a_png() {
|
||||
let png = missing_placeholder().unwrap();
|
||||
|
||||
@@ -53,8 +53,9 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation,
|
||||
#[cfg(test)]
|
||||
use blend_spec::{blend_color_shift, blend_ramp};
|
||||
pub use envelope::{
|
||||
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_content_boxes,
|
||||
envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, smooth_handles,
|
||||
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds,
|
||||
envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin,
|
||||
set_type_ink, smooth_handles,
|
||||
};
|
||||
pub(crate) use expand::max_outset;
|
||||
pub use expand::{
|
||||
|
||||
+394
-17
@@ -1,6 +1,7 @@
|
||||
use super::expand::{Hooks, Outliner, Warper, map_node};
|
||||
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of};
|
||||
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of};
|
||||
use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes};
|
||||
use vectorcraft_geom::Homography;
|
||||
|
||||
/// How an envelope distorts its content.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -127,19 +128,293 @@ impl EnvelopeOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounds of `content` in the axes of `frame`, with its strokes and geometric. Strokes scale
|
||||
/// with the frame as [`Node::transform`] scales them.
|
||||
/// The bounds of `content` in the axes of `frame`, with its strokes and geometric, type measured by
|
||||
/// its glyph outlines (see [`envelope_content_bounds`]). Strokes scale with the frame as
|
||||
/// [`Node::transform`] scales them.
|
||||
pub fn envelope_content_boxes(content: &[Arc<Node>], frame: Affine) -> Option<(Rect, Rect)> {
|
||||
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
|
||||
let to_frame = frame.inverse();
|
||||
let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| {
|
||||
let mut m = (**c).clone();
|
||||
m.transform(to_frame, true);
|
||||
(vectorcraft_geom::union_opt(stroked, m.visual_bounds()), vectorcraft_geom::union_opt(plain, m.geometric_bounds()))
|
||||
(vectorcraft_geom::union_opt(stroked, content_box(c, true, to_frame)), vectorcraft_geom::union_opt(plain, content_box(c, false, to_frame)))
|
||||
});
|
||||
Some((stroked?, plain?))
|
||||
}
|
||||
|
||||
/// The geometric bounds an envelope around `content` on the page axes maps from: type, alone or
|
||||
/// in groups and layers, is measured by its glyph outlines, underlines and strikethroughs instead
|
||||
/// of its laid-out box. Type keeps its laid-out box while nothing is registered with
|
||||
/// [`set_type_ink`], and when it draws nothing.
|
||||
pub fn envelope_content_bounds(content: &[Arc<Node>]) -> Option<Rect> {
|
||||
content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, Affine::IDENTITY)))
|
||||
}
|
||||
|
||||
/// The box of type's glyph outlines, underlines and strikethroughs with its text space mapped by
|
||||
/// the given transform (outlines mapped point by point) and, for type in perspective, then drawn
|
||||
/// through its projection and mapped by the second transform. `None` when it draws nothing. Type is
|
||||
/// laid out above this crate, so the text crate registers one with [`set_type_ink`].
|
||||
pub type TypeInk = fn(&crate::TextObject, Affine, Option<(Homography, Affine)>) -> Option<Rect>;
|
||||
|
||||
/// What [`TypeInk`] maps type's text space by: a transform, and a projection with the transform
|
||||
/// after it.
|
||||
type TypeMap = (Affine, Option<(Homography, Affine)>);
|
||||
|
||||
static TYPE_INK: std::sync::OnceLock<(TypeInk, crate::hit::LiveInputs)> = std::sync::OnceLock::new();
|
||||
|
||||
/// Register how envelopes measure type, and a number that changes whenever what it reads besides
|
||||
/// the type changes, such as the installed fonts (the first registration stays).
|
||||
pub fn set_type_ink(f: TypeInk, inputs: crate::hit::LiveInputs) {
|
||||
// Already set: the same measure, registered by another session, an import or a renderer.
|
||||
let _ = TYPE_INK.set((f, inputs));
|
||||
}
|
||||
|
||||
/// How many entries each table of [`TypeInks`] holds before it is emptied.
|
||||
const TYPE_INK_KEPT: usize = 1024;
|
||||
|
||||
/// `map`, bit for bit, for a table key.
|
||||
fn map_bits(map: &TypeMap) -> [u64; 21] {
|
||||
let mut bits = [0; 21];
|
||||
let (m, projection) = map;
|
||||
for (b, v) in bits.iter_mut().zip(m.as_coeffs()) {
|
||||
*b = v.to_bits();
|
||||
}
|
||||
if let Some((h, after)) = projection {
|
||||
for (b, v) in bits[6..].iter_mut().zip(h.to_array().into_iter().chain(after.as_coeffs())) {
|
||||
*b = v.to_bits();
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
/// A key of [`TypeInks::nodes`]: the object's address, the map its outlines are measured in (an
|
||||
/// affine map without its move) and the inputs number registered with [`set_type_ink`].
|
||||
type NodeKey = (usize, [u64; 21], u64);
|
||||
|
||||
/// A key of [`TypeInks::content`]: two 64-bit hashes of the type without its transform's move and
|
||||
/// its layout caches ([`content_hash`]), the map its outlines are measured in, and the inputs number.
|
||||
type ContentKey = ([u64; 2], [u64; 21], u64);
|
||||
|
||||
/// Type measured by [`type_ink`] on one thread, by the map it is measured in and the inputs
|
||||
/// number: by the object (an unchanged object is found without being read),
|
||||
/// and by its content (a moved or copied object is found without laying it out again). Neither
|
||||
/// table keeps a copy of the type.
|
||||
#[derive(Default)]
|
||||
struct TypeInks {
|
||||
/// By the object's address, with a weak pointer that keeps the address from being reused.
|
||||
nodes: std::collections::HashMap<NodeKey, (std::sync::Weak<Node>, Option<Rect>)>,
|
||||
/// By the type's content.
|
||||
content: std::collections::HashMap<ContentKey, Option<Rect>>,
|
||||
}
|
||||
|
||||
/// Where [`TypeInks::find`] did not find type: its keys in both tables (no content key for type
|
||||
/// that could not be hashed whole).
|
||||
struct Missing {
|
||||
by_node: NodeKey,
|
||||
content: Option<ContentKey>,
|
||||
}
|
||||
|
||||
impl TypeInks {
|
||||
/// The kept box of type node `n` (holding `t`) mapped by `map`, or the keys to keep it under
|
||||
/// once it is measured.
|
||||
fn find(&mut self, n: &Arc<Node>, t: &crate::TextObject, map: &TypeMap, inputs: u64) -> Result<Option<Rect>, Box<Missing>> {
|
||||
// While the table holds a weak pointer to `n`'s allocation, no other object can be at that
|
||||
// address, and `n` can't be changed in place (`Arc::get_mut` refuses it and `Arc::make_mut`
|
||||
// moves it).
|
||||
let by_node = (Arc::as_ptr(n) as usize, map_bits(map), inputs);
|
||||
if let Some((_, ink)) = self.nodes.get(&by_node) {
|
||||
return Ok(*ink);
|
||||
}
|
||||
// Shrink Text to Fit lays type out by its size on the page, so only a move keeps its layout.
|
||||
let [a, b, c, d, _, _] = t.xf.as_coeffs();
|
||||
let mut text = t.clone();
|
||||
text.xf = Affine::new([a, b, c, d, 0.0, 0.0]);
|
||||
text.cached_bounds = None;
|
||||
text.cached_baselines.clear();
|
||||
let content = content_hash(&text).map(|hash| (hash, by_node.1, inputs));
|
||||
match content.and_then(|key| self.content.get(&key)) {
|
||||
Some(ink) => {
|
||||
let ink = *ink;
|
||||
self.keep_node(n, by_node, ink);
|
||||
Ok(ink)
|
||||
}
|
||||
None => Err(Box::new(Missing { by_node, content })),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the box `ink` measured for type node `n` under the keys `missing`.
|
||||
fn keep(&mut self, n: &Arc<Node>, missing: Missing, ink: Option<Rect>) {
|
||||
if let Some(key) = missing.content {
|
||||
if self.content.len() >= TYPE_INK_KEPT {
|
||||
self.content.clear();
|
||||
}
|
||||
self.content.insert(key, ink);
|
||||
}
|
||||
self.keep_node(n, missing.by_node, ink);
|
||||
}
|
||||
|
||||
fn keep_node(&mut self, n: &Arc<Node>, by_node: NodeKey, ink: Option<Rect>) {
|
||||
if self.nodes.len() >= TYPE_INK_KEPT {
|
||||
self.nodes.retain(|_, (weak, _)| weak.strong_count() > 0);
|
||||
if self.nodes.len() >= TYPE_INK_KEPT {
|
||||
self.nodes.clear();
|
||||
}
|
||||
}
|
||||
self.nodes.insert(by_node, (Arc::downgrade(n), ink));
|
||||
}
|
||||
}
|
||||
|
||||
/// Two independent 64-bit hashes of `text` as saved (the layout caches are not saved), with what
|
||||
/// layout reads that is not saved: whether its first paragraph continues a thread and the bounds
|
||||
/// of its inline graphics. `None` when it can't be written whole.
|
||||
fn content_hash(text: &crate::TextObject) -> Option<[u64; 2]> {
|
||||
use std::hash::Hasher;
|
||||
/// Feeds what is written to two hashers.
|
||||
struct Feed([std::collections::hash_map::DefaultHasher; 2]);
|
||||
impl std::io::Write for Feed {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
for h in &mut self.0 {
|
||||
h.write(buf);
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut feed = Feed(Default::default());
|
||||
// The second hasher starts from another state.
|
||||
feed.0[1].write_u8(1);
|
||||
serde_json::to_writer(&mut feed, text).ok()?;
|
||||
let inline_bounds = text.runs.iter().filter_map(|r| r.inline.as_ref()).map(|i| i.bounds.map(|b| [b.x0, b.y0, b.x1, b.y1].map(f64::to_bits)));
|
||||
for h in &mut feed.0 {
|
||||
h.write_u8(u8::from(text.first_para_continues));
|
||||
for b in inline_bounds.clone() {
|
||||
match b {
|
||||
None => h.write_u8(0),
|
||||
Some(bits) => {
|
||||
h.write_u8(1);
|
||||
bits.into_iter().for_each(|v| h.write_u64(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let [a, b] = feed.0;
|
||||
Some([a.finish(), b.finish()])
|
||||
}
|
||||
|
||||
std::thread_local! {
|
||||
/// The type [`type_ink`] measured on this thread.
|
||||
static TYPE_INKS: std::cell::RefCell<TypeInks> = std::cell::RefCell::new(TypeInks::default());
|
||||
}
|
||||
|
||||
/// The box of the glyph outlines of type node `n` (holding `t`) with its text space mapped by `m`
|
||||
/// ([`set_type_ink`]) and, for type in perspective, drawn through `projection` and mapped by its
|
||||
/// second transform; measured again when `t`, the maps (without a move of `m` when there is no
|
||||
/// projection) or the registered inputs number change. `None` while nothing is registered.
|
||||
fn type_ink(n: &Arc<Node>, t: &crate::TextObject, m: Affine, projection: Option<(Homography, Affine)>) -> Option<Option<Rect>> {
|
||||
let (f, inputs) = TYPE_INK.get()?;
|
||||
let inputs = inputs();
|
||||
// Without a projection the type is measured without the move, which is added to the box.
|
||||
let [a, b, c, d, e, g] = m.as_coeffs();
|
||||
let (map, offset) = match projection {
|
||||
None => ((Affine::new([a, b, c, d, 0.0, 0.0]), None), Vec2::new(e, g)),
|
||||
Some(p) => ((m, Some(p)), Vec2::ZERO),
|
||||
};
|
||||
let ink = match TYPE_INKS.with_borrow_mut(|kept| kept.find(n, t, &map, inputs)) {
|
||||
Ok(ink) => ink,
|
||||
Err(missing) => {
|
||||
// Measured outside the borrow: laying type out may measure other type.
|
||||
let ink = f(t, map.0, map.1);
|
||||
TYPE_INKS.with_borrow_mut(|kept| kept.keep(n, *missing, ink));
|
||||
ink
|
||||
}
|
||||
};
|
||||
Some(ink.map(|r| r + offset))
|
||||
}
|
||||
|
||||
/// Add to `typed` the address of `n` and of each group and layer in it that holds type, which
|
||||
/// [`content_box`] measures by its glyphs; is there type in `n`? One walk of the tree.
|
||||
fn find_type(n: &Node, typed: &mut std::collections::HashSet<usize>) -> bool {
|
||||
let holds = match &n.kind {
|
||||
NodeKind::Text(_) => true,
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().fold(false, |holds, c| find_type(c, typed) | holds),
|
||||
_ => false,
|
||||
};
|
||||
if holds {
|
||||
typed.insert(n as *const Node as usize);
|
||||
}
|
||||
holds
|
||||
}
|
||||
|
||||
/// The box of `n` an envelope maps from, in the axes `to` takes the page to: its geometric bounds,
|
||||
/// or with `stroked` its visual bounds (strokes scaled by `to` as [`Node::transform`] scales them),
|
||||
/// with type measured by its glyph outlines ([`type_ink`]) instead of its laid-out box, in groups
|
||||
/// and layers too. Type is laid out as it is on the page, and its outlines are then taken into
|
||||
/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box.
|
||||
fn content_box(n: &Arc<Node>, stroked: bool, to: Affine) -> Option<Rect> {
|
||||
let mut typed = std::collections::HashSet::new();
|
||||
find_type(n, &mut typed);
|
||||
content_box_in(n, stroked, to, &typed)
|
||||
}
|
||||
|
||||
/// [`content_box`], with the addresses of the objects in `n` that hold type ([`find_type`]).
|
||||
fn content_box_in(n: &Arc<Node>, stroked: bool, to: Affine, typed: &std::collections::HashSet<usize>) -> Option<Rect> {
|
||||
// `n` in the axes of `to`.
|
||||
let moved = || {
|
||||
if to == Affine::IDENTITY {
|
||||
return std::borrow::Cow::Borrowed(&**n);
|
||||
}
|
||||
let mut m = (**n).clone();
|
||||
m.transform(to, true);
|
||||
std::borrow::Cow::Owned(m)
|
||||
};
|
||||
let own = |m: &Node| if stroked { m.visual_bounds() } else { m.geometric_bounds() };
|
||||
if !typed.contains(&(Arc::as_ptr(n) as usize)) {
|
||||
return own(&moved());
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
// Type in perspective: its outlines as its projection draws them.
|
||||
let ink = match n.projection() {
|
||||
None => type_ink(n, t, to * t.xf, None),
|
||||
Some(h) => type_ink(n, t, t.xf, Some((h, to))),
|
||||
}
|
||||
.flatten();
|
||||
let m = moved();
|
||||
let Some(ink) = ink else { return own(&m) };
|
||||
if !stroked {
|
||||
return Some(ink);
|
||||
}
|
||||
let (Some(v), Some(g)) = (m.visual_bounds(), m.geometric_bounds()) else { return Some(ink) };
|
||||
Some(Rect::new(
|
||||
ink.x0 - (g.x0 - v.x0).max(0.0),
|
||||
ink.y0 - (g.y0 - v.y0).max(0.0),
|
||||
ink.x1 + (v.x1 - g.x1).max(0.0),
|
||||
ink.y1 + (v.y1 - g.y1).max(0.0),
|
||||
))
|
||||
}
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
// A clipping path that paints strokes over the clip edge, as in `Node::visual_bounds`.
|
||||
let clip = children.first()?;
|
||||
content_box_in(clip, stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)), to, typed)
|
||||
}
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
|
||||
let layer = matches!(n.kind, NodeKind::Layer { .. });
|
||||
let members = n.editing_children().iter();
|
||||
if stroked {
|
||||
// The container's own strokes, scaled by `to` as `Node::transform` scales them,
|
||||
// without copying its members.
|
||||
let mut own_strokes = Node::new(n.id, NodeKind::Group { children: Vec::new(), clip: false });
|
||||
own_strokes.appearance = n.appearance.clone();
|
||||
own_strokes.transform(to, true);
|
||||
own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed)))
|
||||
} else {
|
||||
members.filter(|c| c.visible || !layer).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed)))
|
||||
}
|
||||
}
|
||||
_ => own(&moved()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`EnvelopeOptions::bounds_margin`] of content with bounds `stroked` and `plain`. `None` for
|
||||
/// content with no width or no height, or for margins an `f32` can't hold.
|
||||
pub fn envelope_stroke_margin(stroked: Rect, plain: Rect) -> Option<[f32; 4]> {
|
||||
@@ -265,20 +540,13 @@ pub struct EnvelopeMap<'a> {
|
||||
}
|
||||
|
||||
impl<'a> EnvelopeMap<'a> {
|
||||
/// The map of an envelope of `kind` around `content` with `frame` and `options`. `None` when
|
||||
/// the content has no bounds.
|
||||
/// The map of an envelope of `kind` around `content` with `frame` and `options`, from the
|
||||
/// content's bounds with type measured by its glyph outlines ([`envelope_content_bounds`]).
|
||||
/// `None` when the content has no bounds.
|
||||
pub fn new(content: &[Arc<Node>], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option<Self> {
|
||||
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
|
||||
let to_frame = frame.inverse();
|
||||
let src = if frame == Affine::IDENTITY {
|
||||
nodes_bounds(content)?
|
||||
} else {
|
||||
content.iter().try_fold(None, |acc, c| {
|
||||
let mut m = (**c).clone();
|
||||
m.transform(to_frame, false);
|
||||
Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds()))
|
||||
})??
|
||||
};
|
||||
let src = content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, to_frame)))?;
|
||||
let src = options.source_box(src);
|
||||
Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) })
|
||||
}
|
||||
@@ -378,3 +646,112 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
|
||||
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame, options)?.bounds(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{CharStyle, TextObject};
|
||||
|
||||
/// Look `n` up in `kept`, measuring it (and counting the measures) when it isn't there.
|
||||
fn look_up(kept: &mut TypeInks, n: &Arc<Node>, linear: Affine, measured: &mut usize) -> Option<Rect> {
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("type") };
|
||||
match kept.find(n, t, &(linear, None), 0) {
|
||||
Ok(ink) => ink,
|
||||
Err(missing) => {
|
||||
*measured += 1;
|
||||
let ink = Some(Rect::new(0.0, 0.0, *measured as f64, 1.0));
|
||||
kept.keep(n, *missing, ink);
|
||||
ink
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn type_node(i: usize, text: &str) -> Arc<Node> {
|
||||
let t = TextObject::point(Point::new(i as f64 * 10.0, 0.0), text, CharStyle::default());
|
||||
Arc::new(Node::new(NodeId(i as u64), NodeKind::Text(Box::new(t))))
|
||||
}
|
||||
|
||||
/// Many type objects are each measured once however often they are looked up; a moved or copied
|
||||
/// object is found by its content, and a changed one or another transform is measured again.
|
||||
#[test]
|
||||
fn measured_type_is_kept_for_many_objects() {
|
||||
let mut kept = TypeInks::default();
|
||||
let mut measured = 0;
|
||||
let nodes: Vec<_> = (0..200).map(|i| type_node(i, &format!("Type {i}"))).collect();
|
||||
for _ in 0..5 {
|
||||
for n in &nodes {
|
||||
look_up(&mut kept, n, Affine::IDENTITY, &mut measured);
|
||||
}
|
||||
}
|
||||
assert_eq!(measured, 200);
|
||||
let mut moved = (*nodes[7]).clone();
|
||||
moved.transform(Affine::translate((300.0, 40.0)), true);
|
||||
let moved = Arc::new(moved);
|
||||
assert_eq!(look_up(&mut kept, &moved, Affine::IDENTITY, &mut measured), look_up(&mut kept, &nodes[7], Affine::IDENTITY, &mut measured));
|
||||
assert_eq!(measured, 200, "a moved object is found by its content");
|
||||
let changed = type_node(7, "Changed");
|
||||
look_up(&mut kept, &changed, Affine::IDENTITY, &mut measured);
|
||||
assert_eq!(measured, 201, "a changed object is measured again");
|
||||
look_up(&mut kept, &nodes[7], Affine::rotate(0.5), &mut measured);
|
||||
assert_eq!(measured, 202, "another transform is measured again");
|
||||
}
|
||||
|
||||
/// Type that differs only in what layout reads but files don't save (a first paragraph that
|
||||
/// continues a thread, the resolved bounds of an inline graphic) is measured again.
|
||||
#[test]
|
||||
fn type_that_differs_in_what_is_not_saved_is_measured_again() {
|
||||
let mut kept = TypeInks::default();
|
||||
let mut measured = 0;
|
||||
let mut t = TextObject::point(Point::ZERO, "Type", CharStyle::default());
|
||||
t.runs.push(crate::TextRun::new("", CharStyle::default()));
|
||||
t.runs[1].inline = Some(crate::InlineArt { symbol: "S".into(), scale: 1.0, baseline_shift: 0.0, bounds: None });
|
||||
let node = |t: &TextObject| Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
|
||||
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
|
||||
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
|
||||
assert_eq!(measured, 1, "a copy is found by its content");
|
||||
t.first_para_continues = true;
|
||||
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
|
||||
assert_eq!(measured, 2, "a first paragraph that continues a thread");
|
||||
if let Some(inline) = &mut t.runs[1].inline {
|
||||
inline.bounds = Some(Rect::new(0.0, 0.0, 10.0, 20.0));
|
||||
}
|
||||
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
|
||||
assert_eq!(measured, 3, "an inline graphic's bounds");
|
||||
}
|
||||
|
||||
/// A stroked group of type in a scaled frame reaches past its members by its own strokes scaled
|
||||
/// with the frame, as the group moved into the frame does; deeply nested groups are measured in
|
||||
/// one walk each.
|
||||
#[test]
|
||||
fn a_stroked_group_of_type_is_measured_with_its_strokes_in_the_frame() {
|
||||
let text = type_node(1, "Group");
|
||||
let mut group = Node::group(NodeId(2), vec![text.clone()]);
|
||||
group.appearance = crate::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::solid(Color::BLACK), 4.0).into();
|
||||
let group = Arc::new(group);
|
||||
let to = Affine::scale(2.0);
|
||||
let mut moved = (*group).clone();
|
||||
moved.transform(to, true);
|
||||
let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to)));
|
||||
assert!(want.is_some());
|
||||
assert_eq!(content_box(&group, true, to), want);
|
||||
let mut nested = group;
|
||||
for i in 0..300 {
|
||||
nested = Arc::new(Node::group(NodeId(10 + i), vec![nested]));
|
||||
}
|
||||
assert!(content_box(&nested, false, Affine::IDENTITY).is_some());
|
||||
}
|
||||
|
||||
/// A table that fills up is emptied instead of growing, and what is looked up after that is
|
||||
/// measured again.
|
||||
#[test]
|
||||
fn the_tables_stay_bounded() {
|
||||
let mut kept = TypeInks::default();
|
||||
let mut measured = 0;
|
||||
let nodes: Vec<_> = (0..TYPE_INK_KEPT + 10).map(|i| type_node(i, &format!("Type {i}"))).collect();
|
||||
for n in &nodes {
|
||||
look_up(&mut kept, n, Affine::IDENTITY, &mut measured);
|
||||
}
|
||||
assert!(kept.content.len() <= TYPE_INK_KEPT && kept.nodes.len() <= TYPE_INK_KEPT, "{} {}", kept.content.len(), kept.nodes.len());
|
||||
assert_eq!(measured, TYPE_INK_KEPT + 10);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! those links (and colours) wherever art and swatches hold them, copying only the nodes that
|
||||
//! change.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::swatch::REGISTRATION;
|
||||
@@ -85,6 +86,12 @@ impl Document {
|
||||
fn numbered_swatch_name(&self, base: &str, from: usize) -> String {
|
||||
(from..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
/// Names for several new swatches: [`FreeSwatchNames::take`] gives what
|
||||
/// [`Document::free_swatch_name`] gives once the swatches named before it are added.
|
||||
pub fn free_swatch_names(&self) -> FreeSwatchNames {
|
||||
let taken = self.swatches_iter().map(|s| s.name.clone()).chain(self.swatch_groups.iter().map(|g| g.name.clone()));
|
||||
FreeSwatchNames { taken: taken.chain([REGISTRATION.to_string()]).collect(), next: HashMap::new() }
|
||||
}
|
||||
/// The name a new swatch of `paint` gets by default, unused in the document: a colour's values
|
||||
/// ([`color_name`], made free like [`Document::free_swatch_name`]), or "New Gradient Swatch 1",
|
||||
/// "New Pattern Swatch 1"…
|
||||
@@ -200,6 +207,52 @@ pub fn color_name(c: Color) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The swatch and color group names of a document ([`Document::free_swatch_names`]) and the names
|
||||
/// handed out since, for naming many new swatches without searching the document for each name.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FreeSwatchNames {
|
||||
taken: HashSet<String>,
|
||||
/// For each base and first number already numbered, the number its next numbered name is
|
||||
/// looked for from: names are only ever added, so the numbers below it stay taken.
|
||||
next: HashMap<(String, usize), usize>,
|
||||
}
|
||||
|
||||
impl FreeSwatchNames {
|
||||
/// `base`, or the first of "base 2", "base 3"… not taken, as [`Document::free_swatch_name`]
|
||||
/// gives it; the name is taken from then on.
|
||||
pub fn take(&mut self, base: &str) -> String {
|
||||
let name = if self.taken.contains(base) { self.numbered(base, 2) } else { base.to_string() };
|
||||
self.taken.insert(name.clone());
|
||||
name
|
||||
}
|
||||
/// The name [`Document::new_swatch_name`] gives a new swatch of `paint`; the name is taken
|
||||
/// from then on.
|
||||
pub fn take_new(&mut self, paint: &Paint) -> String {
|
||||
let base = match paint {
|
||||
Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => return self.take(&format!("{n} {}%", tint_percent(*tint))),
|
||||
Paint::Solid { color, .. } => return self.take(&color_name(*color)),
|
||||
Paint::Gradient(_) => "New Gradient Swatch",
|
||||
_ => "New Pattern Swatch",
|
||||
};
|
||||
let name = self.numbered(base, 1);
|
||||
self.taken.insert(name.clone());
|
||||
name
|
||||
}
|
||||
/// Count `name` as taken (a swatch or color group added another way).
|
||||
pub fn insert(&mut self, name: &str) {
|
||||
self.taken.insert(name.to_string());
|
||||
}
|
||||
/// The first of "base `from`", "base `from + 1`"… not taken.
|
||||
fn numbered(&mut self, base: &str, from: usize) -> String {
|
||||
let key = (base.to_string(), from);
|
||||
let start = self.next.get(&key).copied().unwrap_or(from);
|
||||
let (i, name) =
|
||||
(start..).map(|i| (i, format!("{base} {i}"))).find(|(_, n)| !self.taken.contains(n)).unwrap_or_else(|| (start, base.to_string()));
|
||||
self.next.insert(key, i + 1);
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Document::map_solid_paints`] applies to each linkable colour: `(colour, link, tint)`,
|
||||
/// true when it changed them.
|
||||
pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
|
||||
@@ -419,6 +472,56 @@ mod tests {
|
||||
assert_eq!(d.new_swatch_name(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "R=255 G=0 B=0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_swatch_names_match_naming_each_swatch_as_it_is_added() {
|
||||
let (mut d, _, _) = doc();
|
||||
let mut names = d.free_swatch_names();
|
||||
for base in ["Brand", "Brand", "Mine", "Fresh", "Fresh", "Brand 2", "Brand", REGISTRATION, "Fresh 3", "Fresh", "Mine 2"] {
|
||||
let want = d.free_swatch_name(base);
|
||||
assert_eq!(names.take(base), want, "{base}");
|
||||
d.swatches.push(Swatch { name: want, paint: Paint::solid(Color::BLACK), global: false, spot: false });
|
||||
}
|
||||
let added: Vec<&str> = d.swatches.iter().rev().take(11).rev().map(|w| w.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
added,
|
||||
["Brand 2", "Brand 3", "Mine 2", "Fresh", "Fresh 2", "Brand 2 2", "Brand 4", "[Registration] 2", "Fresh 3", "Fresh 4", "Mine 2 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_swatch_names_match_new_swatch_names_as_swatches_are_added() {
|
||||
let (mut d, _, _) = doc();
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
d.swatches.push(Swatch { name: "New Gradient Swatch 2".into(), paint: Paint::solid(red), global: false, spot: false });
|
||||
let mut names = d.free_swatch_names();
|
||||
// A name taken another way (a group made alongside) counts for both.
|
||||
names.insert("R=255 G=0 B=0 3");
|
||||
d.swatch_groups.push(SwatchGroup { name: "R=255 G=0 B=0 3".into(), swatches: vec![] });
|
||||
let tint = Paint::Solid { color: red, swatch: Some("Brand".into()), tint: 0.4 };
|
||||
let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default())));
|
||||
let paints = [Paint::solid(red), Paint::solid(red), gradient.clone(), tint.clone(), Paint::solid(red), gradient.clone(), tint, gradient];
|
||||
let mut added = vec![];
|
||||
for paint in paints {
|
||||
let want = d.new_swatch_name(&paint);
|
||||
assert_eq!(names.take_new(&paint), want);
|
||||
d.swatches.push(Swatch { name: want.clone(), paint, global: false, spot: false });
|
||||
added.push(want);
|
||||
}
|
||||
assert_eq!(
|
||||
added,
|
||||
[
|
||||
"R=255 G=0 B=0",
|
||||
"R=255 G=0 B=0 2",
|
||||
"New Gradient Swatch 1",
|
||||
"Brand 40%",
|
||||
"R=255 G=0 B=0 4",
|
||||
"New Gradient Swatch 3",
|
||||
"Brand 40% 2",
|
||||
"New Gradient Swatch 4"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_copies_only_changed_nodes() {
|
||||
let (mut d, a, b) = doc();
|
||||
|
||||
@@ -228,7 +228,8 @@ pub struct ParaStyle {
|
||||
pub leading_model: LeadingModel,
|
||||
/// Hanging punctuation (Paragraph panel menu › Burasagari): a comma or full stop ending a line
|
||||
/// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before
|
||||
/// it and imported text keep [`Burasagari::None`].
|
||||
/// it and imported text keep [`Burasagari::None`], except legacy `.ai` type with binding
|
||||
/// punctuation on, which opens with [`Burasagari::Forced`].
|
||||
pub burasagari: Burasagari,
|
||||
/// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a
|
||||
/// line.
|
||||
|
||||
@@ -84,8 +84,8 @@ pub use reshape::{
|
||||
};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
|
||||
validate_three_d, view_options,
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art,
|
||||
three_d_params_as, validate_solid, validate_three_d, view_options,
|
||||
};
|
||||
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
|
||||
pub use trap::TRAP;
|
||||
|
||||
@@ -39,6 +39,8 @@ pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
/// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a
|
||||
/// document, symbol instances and pattern tiles stay as they are).
|
||||
fn with_hooks<R>(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R {
|
||||
// Envelopes map type from its glyphs here too, without an engine session.
|
||||
vectorcraft_text::register_envelope_ink();
|
||||
let outline = |n: &Node| outline_object(doc, n);
|
||||
let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region));
|
||||
f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc })
|
||||
|
||||
@@ -25,6 +25,32 @@ pub fn is_three_d(id: &str) -> bool {
|
||||
id == crate::REVOLVE || SOLIDS.contains(&id)
|
||||
}
|
||||
|
||||
/// The 3D and Materials panel's object types and the effect that draws each: (type, effect id).
|
||||
/// Plane keeps the art flat, which is what Rotate draws.
|
||||
pub const THREE_D_TYPES: [(&str, &str); 4] = [("plane", ROTATE), ("extrude", EXTRUDE), ("revolve", crate::REVOLVE), ("inflate", INFLATE)];
|
||||
|
||||
/// The parameters of 3D effect `to` for an effect `from` with `params`, as switching the object
|
||||
/// type keeps them: every setting `to` takes keeps its value from `params` (the defaults of `from`
|
||||
/// under them), each rotation angle wrapped into -180° to 180° when `to` is not Revolve, and the
|
||||
/// settings only `to` takes start at their defaults. Settings `to` does not take are dropped.
|
||||
pub fn three_d_params_as(from: &str, params: &Value, to: &str) -> Value {
|
||||
let current = crate::merged_params(from, params);
|
||||
let mut out = crate::default_params(to).and_then(|v| v.as_object().cloned()).unwrap_or_default();
|
||||
for (key, value) in out.iter_mut() {
|
||||
if let Some(v) = current.get(key) {
|
||||
value.clone_from(v);
|
||||
}
|
||||
}
|
||||
if to != crate::REVOLVE {
|
||||
for key in ["rotationX", "rotationY", "rotationZ"] {
|
||||
if let Some(a) = out.get(key).and_then(Value::as_f64).filter(|a| !(-180.0..=180.0).contains(a)) {
|
||||
out.insert(key.into(), serde_json::json!((a + 180.0).rem_euclid(360.0) - 180.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
fn solid(fx: &[Effect]) -> Option<(usize, &Effect)> {
|
||||
fx.iter().enumerate().find(|(_, e)| e.visible && SOLIDS.contains(&e.id.as_str()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Live evaluation without an engine session makes envelopes measure type by its glyph outlines.
|
||||
//! One test in its own process, so nothing else has registered the measure before the evaluation.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
/// The largest distance between the edges of `a` and `b`.
|
||||
fn off(a: Rect, b: Rect) -> f64 {
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_evaluation_measures_type_in_envelopes_by_its_glyphs() {
|
||||
let t = TextObject::point(Point::new(10.0, 60.0), "Type", CharStyle { size: 48.0, ..Default::default() });
|
||||
let n = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
|
||||
let laid = n.geometric_bounds().unwrap();
|
||||
let ink = t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap());
|
||||
assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}");
|
||||
let before = vectorcraft_doc::live::envelope_content_bounds(std::slice::from_ref(&n)).unwrap();
|
||||
assert!(off(before, laid) < 1e-6, "with nothing registered, type keeps its laid-out box: {before:?} vs {laid:?}");
|
||||
|
||||
let _ = vectorcraft_effects::expand_live(None, &n);
|
||||
let after = vectorcraft_doc::live::envelope_content_bounds(&[n]).unwrap();
|
||||
assert!(off(after, ink) < 1e-6, "after live evaluation, type is measured by its glyphs: {after:?} vs {ink:?}");
|
||||
}
|
||||
@@ -66,3 +66,5 @@ vectorcraft-pdf = { workspace = true, features = ["read-count"] }
|
||||
wat = { workspace = true }
|
||||
# Zstandard streams for the Illustrator editing data fuzz tests.
|
||||
ruzstd = { workspace = true }
|
||||
# The JavaScript parser's lexer, compared with the script checker's on the same scripts.
|
||||
vectorcraft-js = { workspace = true }
|
||||
|
||||
+23
-14
@@ -575,21 +575,26 @@ pub(crate) fn named_id(p: &Value) -> Result<Option<NodeId>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects a command targets: explicit `ids` param or the selection.
|
||||
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
// Objects given are used as given: a value that isn't an object id fails rather than the
|
||||
// command acting on the selection instead (#785).
|
||||
match p.get("ids").filter(|v| !v.is_null()) {
|
||||
Some(Value::Array(a)) => return a.iter().map(object_id).collect(),
|
||||
Some(v) => return Err(EngineError::Other(format!("`ids` must be an array of object ids, not {v}"))),
|
||||
None => {}
|
||||
/// The object `p` gives as `key`, for a command that acts on the selection (or makes something new)
|
||||
/// without it: `None` when `p` leaves it out or gives null. Any other value that isn't an object id
|
||||
/// (`"abc"`, `"5"`, `true`, `{}`, `-1`, `2.5`) is an error under `cmd` that names `key`, never the
|
||||
/// selection. The id is not looked up in the document.
|
||||
pub(crate) fn object_param(p: &Value, key: &str, cmd: &str) -> Result<Option<NodeId>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(r)) if key == "id" && r.starts_with('$') => {
|
||||
Err(bad(cmd, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")))
|
||||
}
|
||||
Some(v) => v.as_u64().map(|id| Some(NodeId(id))).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))),
|
||||
}
|
||||
match p.get("id").filter(|v| !v.is_null()) {
|
||||
Some(v @ Value::Number(_)) => Ok(vec![object_id(v)?]),
|
||||
// A step reference a batch couldn't resolve (other strings are other ids, such as an
|
||||
// effect's).
|
||||
Some(Value::String(r)) if r.starts_with('$') => Err(unresolved_reference(r)),
|
||||
_ => Ok(s.doc()?.selection.objects.clone()),
|
||||
}
|
||||
|
||||
/// Objects a command targets: its `ids`, else its `id` as `ids` of one ([`named_ids`]), else the
|
||||
/// selection. An `ids` or `id` that isn't an object id is an error, never the selection.
|
||||
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match named_ids(p)? {
|
||||
Some(ids) => Ok(ids),
|
||||
None => Ok(s.doc()?.selection.objects.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,6 +727,10 @@ pub use pathops::shape_count;
|
||||
/// The gaps View › Show Live Paint Gaps shows on a Live Paint group.
|
||||
pub use buildcmds::live_paint_gaps;
|
||||
|
||||
/// Whether Reset Transformation changes a symbol instance (the Symbols panel menu and the
|
||||
/// Control bar enable it when it changes one).
|
||||
pub use brushsym::reset_transformation_changes;
|
||||
|
||||
/// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`.
|
||||
/// They run as before but the command palette leaves them out (it lists the command once).
|
||||
pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")];
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Fill",
|
||||
["Window", "Appearance"],
|
||||
Some("Cmd+/"),
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row",
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, true)
|
||||
),
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Stroke",
|
||||
["Window", "Appearance"],
|
||||
Some("Cmd+Alt+/"),
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt",
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, false)
|
||||
),
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clear Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode",
|
||||
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
clear_appearance
|
||||
),
|
||||
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reduce to Basic Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects",
|
||||
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
reduce_basic
|
||||
),
|
||||
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack",
|
||||
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
set_item
|
||||
),
|
||||
@@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Item",
|
||||
[],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack",
|
||||
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
remove_item
|
||||
),
|
||||
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Item",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them",
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
duplicate_item
|
||||
),
|
||||
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reorder Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
|
||||
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}",
|
||||
has_doc,
|
||||
move_item
|
||||
),
|
||||
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eyedropper",
|
||||
[],
|
||||
None,
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art). On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Show All Hidden Attributes",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}",
|
||||
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
|
||||
has_doc,
|
||||
show_all_hidden
|
||||
),
|
||||
@@ -162,7 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Appearance",
|
||||
[],
|
||||
None,
|
||||
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds → {source, target}",
|
||||
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {source, target}",
|
||||
has_doc,
|
||||
transfer
|
||||
),
|
||||
@@ -551,12 +551,17 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
// Where the new fill went in on the active item's object, when it went above the active row.
|
||||
let mut above = None;
|
||||
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
|
||||
// The objects that got a new topmost row of the kind: the series stores its paint even when the object showed it.
|
||||
let mut added = Vec::new();
|
||||
for (id, row) in ids.iter().zip(&per) {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
if let Some(i) = n.appearance.item_of_kind(*row, true)
|
||||
&& let Some(paint) = n.appearance.paint_at(Some(i), true).cloned()
|
||||
{
|
||||
n.appearance.insert_item(i + 1, AppearanceItem::Fill(FillLayer::new(paint)));
|
||||
if let Some(kind) = top_row_kind(&n.appearance, i + 1) {
|
||||
added.push((*id, kind));
|
||||
}
|
||||
if active == Some(*id) {
|
||||
above = Some(i + 1);
|
||||
}
|
||||
@@ -591,10 +596,14 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
}
|
||||
} else {
|
||||
n.appearance.items.push(item);
|
||||
added.push((*id, fill));
|
||||
}
|
||||
}
|
||||
}
|
||||
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
let mut rows = top_paint_changed(&before, d, &ids);
|
||||
rows.extend(painted_rows(d, added));
|
||||
super::graph::capture_series_top_paints(d, &rows);
|
||||
Ok(())
|
||||
})?;
|
||||
// A fill added above the active row becomes the active row; otherwise the rows above the new
|
||||
@@ -614,7 +623,8 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
ok()
|
||||
@@ -626,15 +636,37 @@ fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> {
|
||||
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect()
|
||||
}
|
||||
|
||||
/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost row has another opacity in `d` than
|
||||
/// in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`].
|
||||
/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity in `d`
|
||||
/// than in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`].
|
||||
fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> {
|
||||
let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| {
|
||||
d.node(id).and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill).map(AppearanceItem::opacity)).unwrap_or(1.0)
|
||||
d.node(id)
|
||||
.and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(AppearanceItem::opacity))
|
||||
.unwrap_or(1.0)
|
||||
};
|
||||
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect()
|
||||
}
|
||||
|
||||
/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another paint, or a
|
||||
/// stroke another weight, in `d` than in `before` (no row counts as no paint), for
|
||||
/// [`super::graph::capture_series_top_paints`].
|
||||
fn top_paint_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> {
|
||||
let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| d.node(id).map(|n| super::graph::shown_paint(n, fill));
|
||||
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect()
|
||||
}
|
||||
|
||||
/// `rows` (each object with the kind of row) whose topmost visible row of that kind has paint: a command that wrote them
|
||||
/// replaces the paint another mark of the series left on the graph, even when the mark's own row comes out as it was.
|
||||
fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator<Item = (NodeId, bool)>) -> Vec<(NodeId, bool)> {
|
||||
rows.into_iter().filter(|(id, fill)| d.node(*id).is_some_and(|n| !super::graph::shown_paint(n, *fill).0.is_none())).collect()
|
||||
}
|
||||
|
||||
/// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`.
|
||||
fn top_row_kind(ap: &Appearance, at: usize) -> Option<bool> {
|
||||
let fill = ap.items.get(at)?.is_fill();
|
||||
(ap.items.iter().rposition(|it| it.is_fill() == fill && it.visible()) == Some(at)).then_some(fill)
|
||||
}
|
||||
|
||||
/// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A
|
||||
/// group keeps no fill or stroke rows of its own.
|
||||
fn clear(n: &mut Node) {
|
||||
@@ -672,7 +704,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let changed = if copy { vec![target] } else { vec![source, target] };
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed));
|
||||
Ok(count_changed(d, &before, &[source, target]) + usize::from(kept))
|
||||
// The target last: on marks of one series, its paint is the one the series keeps.
|
||||
let mut rows = top_paint_changed(&before, d, &changed);
|
||||
rows.extend(painted_rows(d, both_kinds(&[target])));
|
||||
let painted = super::graph::capture_series_top_paints(d, &rows);
|
||||
Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
Ok(json!({ "source": source.0, "target": target.0 }))
|
||||
@@ -726,7 +762,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
ok()
|
||||
@@ -752,10 +789,6 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (id, at) in ids.iter().zip(&per) {
|
||||
let Some(&idx) = at.first() else { continue };
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let items = &n.appearance.items;
|
||||
if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f) == Some(idx)) {
|
||||
edited.push((*id, fill));
|
||||
}
|
||||
if p.get("weight").is_none()
|
||||
&& let Some(pa) = &paint
|
||||
{
|
||||
@@ -785,10 +818,26 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
*vis = v;
|
||||
}
|
||||
let items = &n.appearance.items;
|
||||
if let Some(fill) =
|
||||
items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f && it.visible()) == Some(idx))
|
||||
{
|
||||
edited.push((*id, fill));
|
||||
}
|
||||
}
|
||||
// The graph keeps an opacity set on the topmost fill or stroke of its series art for when it is drawn again.
|
||||
let kept = p.get("opacity").and_then(Value::as_f64).is_some() && super::graph::capture_series_opacity(d, &edited);
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
// The graph keeps an opacity, paint or weight set on the topmost visible fill or stroke of its series art for when
|
||||
// it is drawn again, and hiding or showing a row changes which row that is.
|
||||
let mut opacities = top_opacity_changed(&before, d, &ids);
|
||||
if p.get("opacity").and_then(Value::as_f64).is_some() {
|
||||
opacities.extend(edited.iter().copied());
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &opacities);
|
||||
// A weight goes with the paint of its stroke; a stroke without paint leaves the series' paint alone.
|
||||
let written = edited.iter().copied().filter(|(_, fill)| paint.is_some() || (!*fill && p.get("weight").is_some()));
|
||||
let mut rows = top_paint_changed(&before, d, &ids);
|
||||
rows.extend(painted_rows(d, written));
|
||||
let painted = super::graph::capture_series_top_paints(d, &rows);
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
if let Some(pa) = &paint {
|
||||
s.remember_paint(pa);
|
||||
@@ -832,7 +881,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
|
||||
ok()
|
||||
@@ -862,6 +912,9 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let per = items_per_target(&before, &ids, &idx, aligned(p, C)?);
|
||||
s.edit("Duplicate Item", |d, _| {
|
||||
let mut any = false;
|
||||
// Copies that became the topmost visible row of their kind: the series stores their paint even when the object
|
||||
// showed it.
|
||||
let mut added = Vec::new();
|
||||
for (id, idx) in ids.iter().zip(&per) {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let has_contents = n.contents_label().is_some();
|
||||
@@ -877,9 +930,11 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if active == Some(*id) {
|
||||
active_at = Some(at);
|
||||
}
|
||||
let count = copies.len();
|
||||
for (k, item) in copies.into_iter().enumerate() {
|
||||
ap.insert_item(at + k, item);
|
||||
}
|
||||
added.extend((at..at + count).filter_map(|k| top_row_kind(ap, k)).map(|kind| (*id, kind)));
|
||||
if let Some(c) = contents.filter(|_| has_contents) {
|
||||
ap.set_contents_at(c);
|
||||
}
|
||||
@@ -893,6 +948,10 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(item) = ap.items.get(i).cloned() {
|
||||
first_at.get_or_insert(i + 1);
|
||||
ap.insert_item(i + 1, item);
|
||||
// Copies below this one go in lower down, so a copy that is the topmost row of its kind stays it.
|
||||
if let Some(kind) = top_row_kind(ap, i + 1) {
|
||||
added.push((*id, kind));
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
@@ -901,6 +960,9 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, format!("no item at index {}", idx[0])));
|
||||
}
|
||||
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
let mut rows = top_paint_changed(&before, d, &ids);
|
||||
rows.extend(painted_rows(d, added));
|
||||
super::graph::capture_series_top_paints(d, &rows);
|
||||
Ok(())
|
||||
})?;
|
||||
// Rows at or above each copy move up by one (by the block, for copies placed at `to`).
|
||||
@@ -918,11 +980,14 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects.
|
||||
fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = require_targets(s, p, "appearance.showAllHidden")?;
|
||||
let before = s.doc()?.doc.clone();
|
||||
let ids = s.edit("Show All Hidden Attributes", |d, _| {
|
||||
let shown: Vec<NodeId> = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect();
|
||||
if shown.is_empty() {
|
||||
return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into()));
|
||||
}
|
||||
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &shown));
|
||||
super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &shown));
|
||||
Ok(shown)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
@@ -962,7 +1027,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
// The moved row stays the active one; the rows it passed shift by one.
|
||||
s.remap_appearance_item(&ids, |a| {
|
||||
@@ -1006,7 +1072,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result<V
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, ids));
|
||||
Ok(count_changed(d, &before, ids) + usize::from(kept))
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, ids));
|
||||
Ok(count_changed(d, &before, ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
let m = idx.len();
|
||||
let landed = first_landed.unwrap_or(to);
|
||||
@@ -1160,8 +1227,41 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
picked.apply(n, &attrs, append);
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &targets));
|
||||
Ok(count_changed(d, &before, &targets) + usize::from(kept))
|
||||
// The paints the pick wrote, with a weight going with the paint of its stroke, as in `set_item`. `append` adds
|
||||
// only the colors of the visible rows the source has.
|
||||
let (fill, stroke) = (attrs.appearance.fill, attrs.appearance.stroke);
|
||||
let source_has = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind);
|
||||
let source_shows = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible());
|
||||
let (paints, opacities) = if append {
|
||||
let rows = (fill.color && source_shows(true), stroke.color && source_shows(false));
|
||||
(rows, rows)
|
||||
} else {
|
||||
(
|
||||
(fill.color, stroke.color || (stroke.weight && source_has(false))),
|
||||
(fill.transparency && source_has(true), stroke.transparency && source_has(false)),
|
||||
)
|
||||
};
|
||||
// A pick of some attributes edits the topmost row of its kind, which may be hidden; `append` and a whole pick write rows.
|
||||
let rows_written = append || (fill == FillAttrs::ALL && stroke == StrokeAttrs::ALL);
|
||||
let top_shows = |id: NodeId, kind: bool| {
|
||||
let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]);
|
||||
items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(|it| it.visible())
|
||||
};
|
||||
let written = |(fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> {
|
||||
targets
|
||||
.iter()
|
||||
.flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))])
|
||||
.flatten()
|
||||
.filter(|(id, kind)| rows_written || top_shows(*id, *kind))
|
||||
.collect()
|
||||
};
|
||||
let mut opacity_rows = top_opacity_changed(&before, d, &targets);
|
||||
opacity_rows.extend(painted_rows(d, written(opacities)));
|
||||
let mut rows = top_paint_changed(&before, d, &targets);
|
||||
rows.extend(painted_rows(d, written(paints)));
|
||||
let kept = super::graph::capture_series_opacity(d, &opacity_rows);
|
||||
let painted = super::graph::capture_series_top_paints(d, &rows);
|
||||
Ok(count_changed(d, &before, &targets) + usize::from(kept || painted))
|
||||
})?;
|
||||
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ use super::{
|
||||
};
|
||||
use crate::EngineError;
|
||||
|
||||
/// Whether Reset Transformation changes a symbol instance.
|
||||
pub use symbols::reset_transformation_changes;
|
||||
|
||||
mod brushes;
|
||||
mod dynamic;
|
||||
mod symbols;
|
||||
@@ -40,8 +43,8 @@ pub(crate) use symbols::{
|
||||
stored_natural_size,
|
||||
};
|
||||
use symbols::{
|
||||
symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_list, symbol_new, symbol_options, symbol_place, symbol_replace,
|
||||
symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update,
|
||||
symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_instance_reset_transform, symbol_list, symbol_new, symbol_options,
|
||||
symbol_place, symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update,
|
||||
};
|
||||
|
||||
const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES;
|
||||
@@ -140,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Brush Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
has_doc,
|
||||
brush_options
|
||||
),
|
||||
@@ -272,6 +275,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
symbol_instance_reset
|
||||
),
|
||||
cmd!(
|
||||
"symbol.instance.resetTransform",
|
||||
"Reset Transformation",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} give the selected symbol instances (in groups too) their symbol's own scale, without rotation, shear or reflection, keeping the center of each one's bounding box in place, as one undo step; other objects, instances in perspective and instances already at their symbol's scale are left as they are, and no undo step is added when nothing changes; an id naming no object, or no symbol instance among the objects, is an error → {ids: the instances changed}",
|
||||
has_selection,
|
||||
symbol_instance_reset_transform
|
||||
),
|
||||
cmd!(
|
||||
"symbol.setCurrent",
|
||||
"Current Symbol",
|
||||
|
||||
@@ -696,6 +696,62 @@ pub(super) fn symbol_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
|
||||
/// How far a transform coefficient may be from Reset Transformation's for the instance to count as
|
||||
/// already reset.
|
||||
const RESET_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
/// The transform Reset Transformation gives instance `n` of the document of `boxes`: its symbol's
|
||||
/// own scale, without rotation, shear or reflection, with the center of its art's bounding box
|
||||
/// where it is. `None` when [`reset_transformation_changes`] is false for `n`.
|
||||
fn reset_transformation(boxes: &vectorcraft_doc::SymbolBoxes, n: &Node) -> Option<Affine> {
|
||||
let d = boxes.document();
|
||||
if !reset_transformation_changes(d, n) {
|
||||
return None;
|
||||
}
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None };
|
||||
let scale = d.symbol_natural_xf(symbol);
|
||||
// The art's box in symbol space (the ±HALF square when the art cannot be measured): its
|
||||
// center keeps its place on the page.
|
||||
let art = boxes.art_bounds(symbol).unwrap_or_else(|| vectorcraft_geom::Rect::new(-HALF, -HALF, HALF, HALF));
|
||||
let center = xf.transform_rect_bbox(art).center();
|
||||
Some(Affine::translate(center - scale * art.center()) * scale)
|
||||
}
|
||||
|
||||
/// Does Reset Transformation change `n`? True for an instance of a symbol `d` holds, not in
|
||||
/// perspective, whose scale, rotation, shear or reflection differs from the symbol's own scale
|
||||
/// (its stored natural size, or the art unscaled when that size is not positive and finite).
|
||||
/// It does not measure the art.
|
||||
pub fn reset_transformation_changes(d: &Document, n: &Node) -> bool {
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return false };
|
||||
if n.extras().perspective.is_some() || !d.symbols.iter().any(|s| s.name == *symbol) {
|
||||
return false;
|
||||
}
|
||||
let natural = d.symbol_natural_xf(symbol).as_coeffs();
|
||||
xf.as_coeffs()[..4].iter().zip(&natural[..4]).any(|(a, b)| (a - b).abs() > RESET_TOLERANCE)
|
||||
}
|
||||
|
||||
pub(super) fn symbol_instance_reset_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_instances(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("no symbol instances selected".into()));
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
let boxes = d.symbol_boxes();
|
||||
let changes: Vec<(NodeId, Affine)> = ids.iter().filter_map(|id| Some((*id, reset_transformation(&boxes, d.node(*id)?)?))).collect();
|
||||
if !changes.is_empty() {
|
||||
s.edit("Reset Transformation", |d, _| {
|
||||
for (id, new) in &changes {
|
||||
if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
*xf = *new;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let changed: Vec<NodeId> = changes.iter().map(|(id, _)| *id).collect();
|
||||
Ok(json!({ "ids": ids_json(&changed) }))
|
||||
}
|
||||
|
||||
pub(super) fn symbol_set_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = resolve_symbol(&s.doc()?.doc, p, "symbol.setCurrent")?;
|
||||
set_unknown(s, CURRENT_SYMBOL, Some(json!(name)))?;
|
||||
|
||||
@@ -241,9 +241,29 @@ pub(super) struct Used {
|
||||
impl Used {
|
||||
fn of<'a>(defs: &Defs<'a>, nodes: impl IntoIterator<Item = &'a Node>) -> Self {
|
||||
let mut used = Used::default();
|
||||
let mut todo: Vec<Source<'a>> = nodes.into_iter().map(Source::Node).collect();
|
||||
used.follow(defs, nodes.into_iter().map(Source::Node).collect());
|
||||
used
|
||||
}
|
||||
|
||||
/// Every character style of `defs` (with `chars`) and every paragraph style (with `paras`), and
|
||||
/// the resources they use, styles of the other kind included.
|
||||
fn of_text_styles(defs: &Defs<'_>, chars: bool, paras: bool) -> Self {
|
||||
let mut used = Used::default();
|
||||
let mut todo = vec![];
|
||||
for s in defs.char_styles.iter().filter(|_| chars) {
|
||||
used.note(defs, Ref::Name(Res::CharStyle, &s.name), &mut todo);
|
||||
}
|
||||
for s in defs.para_styles.iter().filter(|_| paras) {
|
||||
used.note(defs, Ref::Name(Res::ParaStyle, &s.name), &mut todo);
|
||||
}
|
||||
used.follow(defs, todo);
|
||||
used
|
||||
}
|
||||
|
||||
/// Note the references of `todo`'s sources and of the definitions they lead to.
|
||||
fn follow<'a>(&mut self, defs: &Defs<'a>, mut todo: Vec<Source<'a>>) {
|
||||
while let Some(src) = todo.pop() {
|
||||
let mut found = |r: Ref<'_>| used.note(defs, r, &mut todo);
|
||||
let mut found = |r: Ref<'_>| self.note(defs, r, &mut todo);
|
||||
match src {
|
||||
Source::Node(n) => node_refs(n, &mut found),
|
||||
Source::Appearance(a) => appearance_refs(a, &mut found),
|
||||
@@ -251,7 +271,6 @@ impl Used {
|
||||
Source::Paint(p) => paint_refs(p, &mut found),
|
||||
}
|
||||
}
|
||||
used
|
||||
}
|
||||
|
||||
/// Note `r`; a definition seen for the first time joins `todo`.
|
||||
@@ -505,7 +524,7 @@ impl SwatchChoices {
|
||||
}
|
||||
|
||||
/// How pasted art is relinked to the document's resources.
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Relink {
|
||||
renames: BTreeMap<Res, BTreeMap<String, String>>,
|
||||
/// Graphic style id on the clipboard → its id in the document. Links to other ids are dropped.
|
||||
@@ -665,10 +684,234 @@ impl SymbolOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// A character or paragraph style, symbol (with the Symbol Options copied with it), pattern or
|
||||
/// brush (placed with the others only when they are placed in use order) to place
|
||||
/// ([`Clipboard::import_used`]).
|
||||
#[derive(Clone, Copy)]
|
||||
enum Def<'c> {
|
||||
Style(Res, &'c TextStyleDef),
|
||||
Symbol(&'c Symbol, Option<&'c SymbolOptions>),
|
||||
Pattern(&'c PatternDef),
|
||||
Brush(&'c Brush),
|
||||
}
|
||||
|
||||
impl<'c> Def<'c> {
|
||||
fn key(&self) -> (Res, &'c str) {
|
||||
match self {
|
||||
Def::Style(r, s) => (*r, &s.name),
|
||||
Def::Symbol(s, _) => (Res::Symbol, &s.name),
|
||||
Def::Pattern(p) => (Res::Pattern, &p.name),
|
||||
Def::Brush(b) => (Res::Brush, &b.name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Call `f` with each resource it refers to by name.
|
||||
fn each_use(&self, f: &mut dyn FnMut(Res, &str)) {
|
||||
let mut g = |r: Ref<'_>| {
|
||||
if let Ref::Name(res, n) = r {
|
||||
f(res, n);
|
||||
}
|
||||
};
|
||||
match self {
|
||||
Def::Style(_, s) => attrs_refs(&s.attrs, &mut g),
|
||||
Def::Symbol(s, _) => node_refs(&s.art, &mut g),
|
||||
Def::Pattern(p) => p.art.iter().for_each(|a| node_refs(a, &mut g)),
|
||||
Def::Brush(b) => b.art().into_iter().for_each(|a| node_refs(a, &mut g)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is it the definition `d` has under `name`, once relinked by `r`? (`brushes`: the brush
|
||||
/// library of `d`.)
|
||||
fn same_in(&self, d: &Document, brushes: &Brushes<'_>, name: &str, r: &Relink) -> bool {
|
||||
match self {
|
||||
Def::Style(res, s) => {
|
||||
let theirs = if *res == Res::CharStyle { &d.char_styles } else { &d.para_styles };
|
||||
let mut attrs = s.attrs.clone();
|
||||
r.attrs(&mut attrs);
|
||||
theirs.iter().any(|x| x.name == name && x.attrs == attrs)
|
||||
}
|
||||
Def::Symbol(s, options) => d.symbols.iter().any(|x| {
|
||||
x.name == name
|
||||
&& options.is_none_or(|o| SymbolOptions::of(d, name) == *o)
|
||||
&& same_art(r, std::slice::from_ref(&x.art), std::slice::from_ref(&s.art))
|
||||
}),
|
||||
Def::Pattern(p) => {
|
||||
let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() };
|
||||
d.pattern(name).is_some_and(|x| bare(x) == bare(p) && same_art(r, &x.art, &p.art))
|
||||
}
|
||||
Def::Brush(b) => brushes.get().iter().any(|x| x.name == name && same_brush(r, x, b)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of its kind in `d` (`brushes`: its brush library) that are its name or a numbered
|
||||
/// copy of it.
|
||||
fn names_in(&self, d: &Document, brushes: &Brushes<'_>) -> Vec<String> {
|
||||
let (res, name) = self.key();
|
||||
let names: Vec<String> = match res {
|
||||
Res::CharStyle => d.char_styles.iter().map(|s| s.name.clone()).collect(),
|
||||
Res::ParaStyle => d.para_styles.iter().map(|s| s.name.clone()).collect(),
|
||||
Res::Symbol => d.symbols.iter().map(|s| s.name.clone()).collect(),
|
||||
Res::Brush => brushes.get().iter().map(|b| b.name.clone()).collect(),
|
||||
_ => d.patterns.iter().map(|p| p.name.clone()).collect(),
|
||||
};
|
||||
names.into_iter().filter(|n| copy_of(name, n)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `defs` in rounds: each round takes those that use none of the others left, in their order.
|
||||
/// → (those, the rest, which use each other in a loop or use one that does, in their order)
|
||||
fn use_order(defs: Vec<Def<'_>>) -> (Vec<Def<'_>>, Vec<Def<'_>>) {
|
||||
let uses = uses_of(&defs);
|
||||
// The round of each definition; `None` for one in a loop or one that uses such a definition.
|
||||
let mut round: Vec<Option<usize>> = vec![None; defs.len()];
|
||||
// A group outside a loop holds one definition, and comes after the groups it uses.
|
||||
for (group, looped) in components(&uses) {
|
||||
for v in group.into_iter().filter(|_| !looped) {
|
||||
let r = uses[v].iter().try_fold(0, |r, &w| round.get(w).copied().flatten().map(|k| r.max(k + 1)));
|
||||
round[v] = r;
|
||||
}
|
||||
}
|
||||
let mut ready: Vec<(usize, usize)> = round.iter().enumerate().filter_map(|(i, r)| r.map(|r| (r, i))).collect();
|
||||
ready.sort_unstable();
|
||||
let order = ready.into_iter().filter_map(|(_, i)| defs.get(i).copied()).collect();
|
||||
let rest = defs.iter().zip(&round).filter(|(_, r)| r.is_none()).map(|(def, _)| *def).collect();
|
||||
(order, rest)
|
||||
}
|
||||
|
||||
/// The positions in `defs` of the definitions each one uses, itself included.
|
||||
fn uses_of(defs: &[Def<'_>]) -> Vec<Vec<usize>> {
|
||||
let mut at: BTreeMap<(Res, &str), Vec<usize>> = BTreeMap::new();
|
||||
for (i, def) in defs.iter().enumerate() {
|
||||
at.entry(def.key()).or_default().push(i);
|
||||
}
|
||||
defs.iter()
|
||||
.map(|def| {
|
||||
let mut to = vec![];
|
||||
def.each_use(&mut |res, n| to.extend(at.get(&(res, n)).into_iter().flatten()));
|
||||
to.sort_unstable();
|
||||
to.dedup();
|
||||
to
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `rest` ([`use_order`]'s definitions that use each other in a loop or use one that does) split
|
||||
/// into its loops and the definitions outside a loop, each group after the groups it uses:
|
||||
/// (positions in `rest`, in their order; true for a loop, definitions that use each other or one
|
||||
/// that uses itself).
|
||||
fn loop_groups(rest: &[Def<'_>]) -> Vec<(Vec<usize>, bool)> {
|
||||
components(&uses_of(rest))
|
||||
}
|
||||
|
||||
/// The strongly connected components of the graph `uses` (each node's successors), each after
|
||||
/// those it reaches: (nodes, in their order; true for a loop, two or more nodes or one that uses
|
||||
/// itself).
|
||||
fn components(uses: &[Vec<usize>]) -> Vec<(Vec<usize>, bool)> {
|
||||
// Tarjan's algorithm, with an explicit stack of calls.
|
||||
const NONE: usize = usize::MAX;
|
||||
let (mut index, mut low, mut on) = (vec![NONE; uses.len()], vec![0; uses.len()], vec![false; uses.len()]);
|
||||
let (mut stack, mut next, mut groups) = (vec![], 0, vec![]);
|
||||
for root in 0..uses.len() {
|
||||
if index[root] != NONE {
|
||||
continue;
|
||||
}
|
||||
let mut calls: Vec<(usize, usize)> = vec![(root, 0)];
|
||||
(index[root], low[root], on[root]) = (next, next, true);
|
||||
next += 1;
|
||||
stack.push(root);
|
||||
while let Some(&(v, k)) = calls.last() {
|
||||
if let Some(&w) = uses[v].get(k) {
|
||||
if let Some(top) = calls.last_mut() {
|
||||
top.1 += 1;
|
||||
}
|
||||
if index[w] == NONE {
|
||||
(index[w], low[w], on[w]) = (next, next, true);
|
||||
next += 1;
|
||||
stack.push(w);
|
||||
calls.push((w, 0));
|
||||
} else if on[w] {
|
||||
low[v] = low[v].min(index[w]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
calls.pop();
|
||||
if let Some(&(u, _)) = calls.last() {
|
||||
low[u] = low[u].min(low[v]);
|
||||
}
|
||||
if low[v] == index[v] {
|
||||
let mut group = vec![];
|
||||
while let Some(w) = stack.pop() {
|
||||
on[w] = false;
|
||||
group.push(w);
|
||||
if w == v {
|
||||
break;
|
||||
}
|
||||
}
|
||||
group.sort_unstable();
|
||||
let looped = group.len() > 1 || uses[v].contains(&v);
|
||||
groups.push((group, looped));
|
||||
}
|
||||
}
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// The names in `d` that `looped` (a loop of definitions that use each other, [`loop_groups`])
|
||||
/// all have at once, each the same once relinked by `r` with those names: each one's name or a
|
||||
/// numbered copy of it. The names are picked one definition at a time, in order, and each
|
||||
/// definition is compared as soon as the names of those it uses are picked, so a pick that does
|
||||
/// not fit is dropped with every combination that follows from it; at most 65,536 comparisons are
|
||||
/// made. `None` when no combination fits, or none was found within that many comparisons. The brush
|
||||
/// library of `d` is read once, the first time a brush is compared.
|
||||
fn names_of_loop(d: &Document, looped: &[Def<'_>], r: &Relink) -> Option<Vec<String>> {
|
||||
let library = OnceCell::new();
|
||||
let brushes = Brushes::Lazy(d, &library);
|
||||
let names: Vec<Vec<String>> = looped.iter().map(|def| def.names_in(d, &brushes)).collect();
|
||||
if names.is_empty() || names.iter().any(Vec::is_empty) {
|
||||
return None;
|
||||
}
|
||||
// The definitions compared once the name at each position is picked.
|
||||
let mut compare_at: Vec<Vec<usize>> = vec![vec![]; looped.len()];
|
||||
for (i, uses) in uses_of(looped).iter().enumerate() {
|
||||
compare_at[uses.iter().copied().fold(i, usize::max)].push(i);
|
||||
}
|
||||
let (mut pick, mut guess, mut budget, mut k) = (vec![0usize; looped.len()], r.clone(), 1usize << 16, 0);
|
||||
loop {
|
||||
let (res, own) = looped[k].key();
|
||||
guess.renames.entry(res).or_default().insert(own.to_string(), names[k][pick[k]].clone());
|
||||
let fits = compare_at[k].iter().all(|&i| {
|
||||
budget = budget.saturating_sub(1);
|
||||
looped[i].same_in(d, &brushes, &names[i][pick[i]], &guess)
|
||||
});
|
||||
if fits && k + 1 == looped.len() {
|
||||
return Some(names.iter().zip(&pick).map(|(l, &p)| l[p].clone()).collect());
|
||||
}
|
||||
if budget == 0 {
|
||||
return None;
|
||||
}
|
||||
if fits {
|
||||
k += 1;
|
||||
pick[k] = 0;
|
||||
continue;
|
||||
}
|
||||
// The next name at this position, or back to the position before.
|
||||
loop {
|
||||
pick[k] += 1;
|
||||
if pick[k] < names[k].len() {
|
||||
break;
|
||||
}
|
||||
k = k.checked_sub(1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What pasting did to the document's resources (part of the paste result).
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Imported {
|
||||
relink: Relink,
|
||||
/// Whether relinking can change a pasted copy: a resource has another name or color in the
|
||||
/// document, or the objects link a graphic style.
|
||||
relinks: bool,
|
||||
/// Resources added to the document (renamed ones included).
|
||||
pub added: usize,
|
||||
/// Swatch conflicts resolved by merging.
|
||||
@@ -690,7 +933,9 @@ impl Imported {
|
||||
}
|
||||
/// Point a pasted copy at the document's resources.
|
||||
pub(crate) fn apply(&self, n: &mut Node) {
|
||||
self.relink.node(n);
|
||||
if self.relinks {
|
||||
self.relink.node(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,6 +944,12 @@ impl Clipboard {
|
||||
pub(crate) fn gather(src: &Document, nodes: Vec<Node>) -> Self {
|
||||
let library = OnceCell::new();
|
||||
let used = Used::of(&Defs::of(src, &library), &nodes);
|
||||
Self::with_used(src, &used, &library, nodes)
|
||||
}
|
||||
|
||||
/// `nodes` and the resources of `src` that `used` names (`library`: the brush library of `src`,
|
||||
/// read when a brush was used).
|
||||
fn with_used(src: &Document, used: &Used, library: &OnceCell<Vec<Brush>>, nodes: Vec<Node>) -> Self {
|
||||
let pick = |r: Res, list: &[TextStyleDef]| list.iter().filter(|s| used.has(r, &s.name)).cloned().collect::<Vec<_>>();
|
||||
// Read when a brush was used.
|
||||
let brushes = match (used.names.get(&Res::Brush), library.get()) {
|
||||
@@ -752,61 +1003,158 @@ impl Clipboard {
|
||||
/// Add the resources the objects use to `d` (inside the paste's undo step) and work out how
|
||||
/// pasted copies relink to them.
|
||||
pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Imported {
|
||||
let used = self.used();
|
||||
self.import_used(d, &self.used(), choices, same_doc, false)
|
||||
}
|
||||
|
||||
/// [`Self::import_into`] for the resources `used` names. The character and paragraph styles
|
||||
/// come before the symbols and patterns, whose art may set type in them; with `in_use_order`,
|
||||
/// each style, symbol, pattern and brush comes after the styles, symbols, patterns and brushes it uses
|
||||
/// ([`use_order`]), so that it is compared and relinked with their names here, and each loop
|
||||
/// of definitions that use each other is matched on its own ([`loop_groups`]).
|
||||
fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported {
|
||||
let mut out = Imported::default();
|
||||
self.import_images(d, &used, same_doc, &mut out);
|
||||
self.import_swatches(d, &used, choices, same_doc, &mut out);
|
||||
self.import_images(d, used, same_doc, &mut out);
|
||||
self.import_swatches(d, used, choices, same_doc, &mut out);
|
||||
// Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art
|
||||
// they may brush; the others once those are in.
|
||||
let mut added_brushes: HashSet<String> = self.import_brushes(d, &used, same_doc, false, &mut out).into_iter().collect();
|
||||
// Character and paragraph styles (their colours relinked first).
|
||||
for (r, mine, theirs) in [(Res::CharStyle, &self.char_styles, &mut d.char_styles), (Res::ParaStyle, &self.para_styles, &mut d.para_styles)] {
|
||||
for def in mine.iter().filter(|s| used.has(r, &s.name)) {
|
||||
let mut s = def.clone();
|
||||
// they may brush; the others once those are in. With `in_use_order`, the brushes are
|
||||
// placed in use order with the others.
|
||||
let mut added_brushes: HashSet<String> =
|
||||
if in_use_order { HashSet::new() } else { self.import_brushes(d, used, same_doc, false, &mut out).into_iter().collect() };
|
||||
let mut defs: Vec<Def<'_>> = vec![];
|
||||
for (r, list) in [(Res::CharStyle, &self.char_styles), (Res::ParaStyle, &self.para_styles)] {
|
||||
defs.extend(list.iter().filter(|s| used.has(r, &s.name)).map(|s| Def::Style(r, s)));
|
||||
}
|
||||
defs.extend(self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)).map(|s| Def::Symbol(s, self.symbol_options.get(&s.name))));
|
||||
defs.extend(self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)).map(Def::Pattern));
|
||||
if in_use_order {
|
||||
defs.extend(self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name)).map(Def::Brush));
|
||||
}
|
||||
// Groups to place in order; true for a loop of definitions that use each other.
|
||||
let groups: Vec<(Vec<Def<'_>>, bool)> = if in_use_order {
|
||||
let (order, rest) = use_order(defs);
|
||||
let loops = loop_groups(&rest).into_iter().map(|(at, looped)| (at.into_iter().filter_map(|i| rest.get(i).copied()).collect(), looped));
|
||||
std::iter::once((order, false)).chain(loops).collect()
|
||||
} else {
|
||||
vec![(defs, false)]
|
||||
};
|
||||
// Styles (their colors relinked first), symbols and patterns (compared once relinked,
|
||||
// symbols with their options too). The symbols and patterns added are relinked at the end,
|
||||
// when every new name and graphic style id is known; with `in_use_order`, so are the
|
||||
// styles added, which a loop of uses may place before a pattern they paint with.
|
||||
let (mut added_symbols, mut added_patterns, mut added_styles) = (HashSet::new(), HashSet::new(), vec![]);
|
||||
// Each definition, whether it is in a loop, and the loop it starts.
|
||||
let steps = groups
|
||||
.iter()
|
||||
.flat_map(|(group, looped)| group.iter().enumerate().map(move |(i, def)| (*def, *looped, (*looped && i == 0).then_some(group))));
|
||||
// In a loop the document does not have all at once, every definition uses one that is
|
||||
// added under a free name, so each one is added too.
|
||||
let mut fresh = false;
|
||||
// The names in the document of the loop being placed, when it has them all at once.
|
||||
let mut matched: BTreeMap<(Res, String), String> = BTreeMap::new();
|
||||
// The document's brush library, read the first time a brush is placed in use order and
|
||||
// stored again after each brush added; nothing else in the loop changes it.
|
||||
let mut library: Option<Vec<Brush>> = None;
|
||||
for (def, looped, starts) in steps {
|
||||
fresh &= looped;
|
||||
if !looped {
|
||||
matched.clear();
|
||||
}
|
||||
// A loop of uses that the document has all at once is known under those names before
|
||||
// its definitions are compared.
|
||||
if let Some(group) = starts {
|
||||
matched.clear();
|
||||
match names_of_loop(d, group, &out.relink) {
|
||||
Some(names) => {
|
||||
for (def, name) in group.iter().zip(names) {
|
||||
let (res, own) = def.key();
|
||||
if own != name {
|
||||
out.relink.renames.entry(res).or_default().insert(own.to_string(), name.clone());
|
||||
}
|
||||
matched.insert((res, own.to_string()), name);
|
||||
}
|
||||
fresh = false;
|
||||
}
|
||||
None => fresh = true,
|
||||
}
|
||||
}
|
||||
// A definition of such a loop is the document's under the name found for it.
|
||||
let (res, own) = def.key();
|
||||
if let Some(name) = matched.get(&(res, own.to_string())) {
|
||||
let p = if name == own { Placed::Existing } else { Placed::Reused(name.clone()) };
|
||||
out.placed(res, own, &p);
|
||||
continue;
|
||||
}
|
||||
match def {
|
||||
Def::Style(r, def) => {
|
||||
let mut s = def.clone();
|
||||
out.relink.attrs(&mut s.attrs);
|
||||
let theirs = if r == Res::CharStyle { &mut d.char_styles } else { &mut d.para_styles };
|
||||
let p = place(theirs, &s, same_doc, |a, b| !fresh && a == b);
|
||||
if in_use_order && let Some(name) = p.new_name(&def.name) {
|
||||
added_styles.push((r, name, def));
|
||||
}
|
||||
out.placed(r, &def.name, &p);
|
||||
}
|
||||
Def::Symbol(def, options) => {
|
||||
let theirs: HashMap<String, SymbolOptions> = match options {
|
||||
Some(_) => d
|
||||
.symbols
|
||||
.iter()
|
||||
.filter(|s| copy_of(&def.name, &s.name))
|
||||
.map(|s| (s.name.clone(), SymbolOptions::of(d, &s.name)))
|
||||
.collect(),
|
||||
None => HashMap::new(),
|
||||
};
|
||||
let r = &out.relink;
|
||||
let p = place(&mut d.symbols, def, same_doc, |a, b| {
|
||||
!fresh
|
||||
&& options.is_none_or(|o| theirs.get(&a.name) == Some(o))
|
||||
&& same_art(r, std::slice::from_ref(&a.art), std::slice::from_ref(&b.art))
|
||||
});
|
||||
if let Some(name) = p.new_name(&def.name) {
|
||||
if let Some(o) = options {
|
||||
o.give(d, &name);
|
||||
}
|
||||
added_symbols.insert(name);
|
||||
}
|
||||
out.placed(Res::Symbol, &def.name, &p);
|
||||
}
|
||||
Def::Pattern(def) => {
|
||||
// Tile options alike, and the art once relinked.
|
||||
let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() };
|
||||
let same = |n: &str| !fresh && d.pattern(n).is_some_and(|x| bare(x) == bare(def) && same_art(&out.relink, &x.art, &def.art));
|
||||
let p = existing(&def.name, d.patterns.iter().map(|p| p.name.as_str()), same_doc, same).unwrap_or_else(|| {
|
||||
// Its swatch comes along under the same name.
|
||||
let name = unique_name(&def.name, |n| d.pattern(n).is_some() || d.swatch_name_taken(n));
|
||||
add_pattern(d, PatternDef { name: name.clone(), ..def.clone() });
|
||||
Placed::added(&def.name, name)
|
||||
});
|
||||
added_patterns.extend(p.new_name(&def.name));
|
||||
out.placed(Res::Pattern, &def.name, &p);
|
||||
}
|
||||
Def::Brush(b) => {
|
||||
let lib = library.get_or_insert_with(|| vectorcraft_brush::library(d));
|
||||
let r = &out.relink;
|
||||
let p = place(lib, b, same_doc, |ours, theirs| !fresh && same_brush(r, ours, theirs));
|
||||
if let Some(name) = p.new_name(&b.name) {
|
||||
vectorcraft_brush::store(d, lib);
|
||||
added_brushes.insert(name);
|
||||
}
|
||||
out.placed(Res::Brush, &b.name, &p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !in_use_order {
|
||||
added_brushes.extend(self.import_brushes(d, used, same_doc, true, &mut out));
|
||||
}
|
||||
self.import_graphic_styles(d, used, same_doc, &mut out);
|
||||
for (r, name, def) in added_styles {
|
||||
let theirs = if r == Res::CharStyle { &mut d.char_styles } else { &mut d.para_styles };
|
||||
if let Some(s) = theirs.iter_mut().find(|s| s.name == name) {
|
||||
s.attrs = def.attrs.clone();
|
||||
out.relink.attrs(&mut s.attrs);
|
||||
let p = place(theirs, &s, same_doc, PartialEq::eq);
|
||||
out.placed(r, &def.name, &p);
|
||||
}
|
||||
}
|
||||
// Symbols and patterns, compared once relinked (symbols with their options too). Those
|
||||
// added are relinked at the end, when every new name and graphic style id is known.
|
||||
let mut added_symbols = HashSet::new();
|
||||
for def in self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)) {
|
||||
let options = self.symbol_options.get(&def.name);
|
||||
let theirs: HashMap<String, SymbolOptions> = match options {
|
||||
Some(_) => {
|
||||
d.symbols.iter().filter(|s| copy_of(&def.name, &s.name)).map(|s| (s.name.clone(), SymbolOptions::of(d, &s.name))).collect()
|
||||
}
|
||||
None => HashMap::new(),
|
||||
};
|
||||
let r = &out.relink;
|
||||
let p = place(&mut d.symbols, def, same_doc, |a, b| {
|
||||
options.is_none_or(|o| theirs.get(&a.name) == Some(o)) && same_art(r, std::slice::from_ref(&a.art), std::slice::from_ref(&b.art))
|
||||
});
|
||||
if let Some(name) = p.new_name(&def.name) {
|
||||
if let Some(o) = options {
|
||||
o.give(d, &name);
|
||||
}
|
||||
added_symbols.insert(name);
|
||||
}
|
||||
out.placed(Res::Symbol, &def.name, &p);
|
||||
}
|
||||
let mut added_patterns = HashSet::new();
|
||||
for def in self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)) {
|
||||
// Tile options alike, and the art once relinked.
|
||||
let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() };
|
||||
let same = |n: &str| d.pattern(n).is_some_and(|x| bare(x) == bare(def) && same_art(&out.relink, &x.art, &def.art));
|
||||
let p = existing(&def.name, d.patterns.iter().map(|p| p.name.as_str()), same_doc, same).unwrap_or_else(|| {
|
||||
// Its swatch comes along under the same name.
|
||||
let name = unique_name(&def.name, |n| d.pattern(n).is_some() || d.swatch_name_taken(n));
|
||||
add_pattern(d, PatternDef { name: name.clone(), ..def.clone() });
|
||||
Placed::added(&def.name, name)
|
||||
});
|
||||
added_patterns.extend(p.new_name(&def.name));
|
||||
out.placed(Res::Pattern, &def.name, &p);
|
||||
}
|
||||
added_brushes.extend(self.import_brushes(d, &used, same_doc, true, &mut out));
|
||||
self.import_graphic_styles(d, &used, same_doc, &mut out);
|
||||
for s in d.symbols.iter_mut().filter(|s| added_symbols.contains(&s.name)) {
|
||||
out.relink.node(Arc::make_mut(&mut s.art));
|
||||
}
|
||||
@@ -822,6 +1170,7 @@ impl Clipboard {
|
||||
}
|
||||
vectorcraft_brush::store(d, &lib);
|
||||
}
|
||||
out.relinks = !(out.relink.renames.is_empty() && out.relink.styles.is_empty() && out.relink.colors.is_empty() && used.styles.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
@@ -919,6 +1268,43 @@ impl Clipboard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a loaded text style is in the document ([`import_text_styles`]).
|
||||
pub(crate) struct LoadedStyle {
|
||||
/// A paragraph style (else a character style).
|
||||
pub para: bool,
|
||||
/// Its name in the document.
|
||||
pub name: String,
|
||||
/// It was added (else the document's own was the same).
|
||||
pub added: bool,
|
||||
}
|
||||
|
||||
/// Add every character style of `src` (with `chars`) and every paragraph style (with `paras`) to
|
||||
/// `d`, with the swatches, patterns and what their art uses (styles of the other kind included)
|
||||
/// that the styles' fill and stroke lead to, as Paste adds those of pasted type
|
||||
/// ([`Clipboard::import_into`]; a global or spot color whose name `d` gives another color is
|
||||
/// `d`'s). Each style, symbol and pattern is placed after those it uses, so that a style follows
|
||||
/// a pattern added under a free name and a pattern follows a style renamed on the way in. A style
|
||||
/// `d` has under its name, or a numbered copy of it ("Body 2"), that is the same once relinked is
|
||||
/// used; any other is added under a free name. → each style of `src` placed, in order, character
|
||||
/// styles first.
|
||||
pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Vec<LoadedStyle> {
|
||||
let library = OnceCell::new();
|
||||
let used = Used::of_text_styles(&Defs::of(src, &library), chars, paras);
|
||||
let clip = Clipboard::with_used(src, &used, &library, vec![]);
|
||||
let before = [&d.char_styles, &d.para_styles].map(|l| l.iter().map(|s| s.name.clone()).collect::<BTreeSet<_>>());
|
||||
let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true);
|
||||
let mut placed: Vec<LoadedStyle> = vec![];
|
||||
for (para, r, list) in [(false, Res::CharStyle, &clip.char_styles), (true, Res::ParaStyle, &clip.para_styles)] {
|
||||
for s in list {
|
||||
let name = out.relink.renamed(r, &s.name);
|
||||
let again = placed.iter().any(|p| p.para == para && p.name == name);
|
||||
let added = !again && !before[usize::from(para)].contains(&name);
|
||||
placed.push(LoadedStyle { para, name, added });
|
||||
}
|
||||
}
|
||||
placed
|
||||
}
|
||||
|
||||
/// Swatch `w` in `d` ([`existing`]), else added under a free name (swatches and colour groups
|
||||
/// share one namespace).
|
||||
fn add_swatch(d: &mut Document, w: &Swatch, same_doc: bool) -> Placed {
|
||||
|
||||
@@ -19,7 +19,7 @@ use vectorcraft_render::proof;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::EngineError;
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, color_value, has_doc, str_param, targets};
|
||||
use super::{CommandSpec, Result, Session, always, bad, cmd, color_value, has_doc, switch_or, targets, text_param};
|
||||
|
||||
/// `Document.unknown` key under which files before format v3 kept the assigned profiles (migrated
|
||||
/// to `Document::color_profiles` on load).
|
||||
@@ -100,7 +100,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
proof_setup
|
||||
),
|
||||
cmd!(query "view.proofColors", "Proof Colors", ["View"], None, "{on?: bool (default: toggle)} → proof state", always, |s, p| {
|
||||
toggle_view(s, p, |v, on| v.proof_colors = on.unwrap_or(!v.proof_colors))
|
||||
toggle_view(s, p, "view.proofColors", |v, on| v.proof_colors = on.unwrap_or(!v.proof_colors))
|
||||
}),
|
||||
cmd!(
|
||||
query "view.overprintPreview",
|
||||
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some("Cmd+Alt+Shift+Y"),
|
||||
"{on?: bool (default: toggle)} → proof state",
|
||||
always,
|
||||
|s, p| toggle_view(s, p, |v, on| v.overprint = on.unwrap_or(!v.overprint))
|
||||
|s, p| toggle_view(s, p, "view.overprintPreview", |v, on| v.overprint = on.unwrap_or(!v.overprint))
|
||||
),
|
||||
cmd!(
|
||||
query "view.separationsPreview",
|
||||
@@ -143,12 +143,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
fn intent_param(p: &Value, cmd: &str) -> Result<Option<Intent>> {
|
||||
match str_param(p, "intent") {
|
||||
match text_param(p, "intent", cmd)? {
|
||||
None => Ok(None),
|
||||
Some(i) => Intent::parse(i).map(Some).ok_or_else(|| bad(cmd, format!("unknown intent `{i}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The true or false `p` gives for `key`, or `None` when `p` leaves it out or gives null. Any other
|
||||
/// value is an error, as [`switch_or`] reads it.
|
||||
fn on_param(p: &Value, key: &str, cmd: &str) -> Result<Option<bool>> {
|
||||
if p.get(key).is_none_or(Value::is_null) {
|
||||
return Ok(None);
|
||||
}
|
||||
switch_or(p, key, false, cmd).map(Some)
|
||||
}
|
||||
|
||||
fn settings_json(st: &ColorSettings) -> Value {
|
||||
json!({
|
||||
"rgb": st.rgb,
|
||||
@@ -171,16 +180,16 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.colorSettings";
|
||||
let mut st = cms::active_settings();
|
||||
let before = st.clone();
|
||||
if let Some(v) = str_param(p, "rgb") {
|
||||
if let Some(v) = text_param(p, "rgb", C)? {
|
||||
st.rgb = cms::canonical_name(v).to_string();
|
||||
}
|
||||
if let Some(v) = str_param(p, "cmyk") {
|
||||
if let Some(v) = text_param(p, "cmyk", C)? {
|
||||
st.cmyk = cms::canonical_name(v).to_string();
|
||||
}
|
||||
if let Some(i) = intent_param(p, C)? {
|
||||
st.intent = i;
|
||||
}
|
||||
st.bpc = bool_or(p, "bpc", st.bpc);
|
||||
st.bpc = switch_or(p, "bpc", st.bpc, C)?;
|
||||
if st != before {
|
||||
cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?;
|
||||
touch_all(s);
|
||||
@@ -191,7 +200,7 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "color.loadProfile";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let path = text_param(p, "path", C)?.ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
crate::file_access::check_read(path).map_err(EngineError::Other)?;
|
||||
let bytes = crate::store::read_capped(std::path::Path::new(path), crate::store::MAX_PROFILE_FILE).map_err(|e| bad(C, format!("{path}: {e}")))?;
|
||||
let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?;
|
||||
@@ -209,7 +218,7 @@ fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Forget a profile `color.loadProfile` loaded, and the copy that is kept of it.
|
||||
fn remove_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "color.removeProfile";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let name = text_param(p, "name", C)?.ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let info = cms::profiles().into_iter().find(|i| i.name == name).ok_or_else(|| bad(C, format!("no profile named `{name}`")))?;
|
||||
if info.builtin {
|
||||
return Err(bad(C, format!("`{name}` is a built-in profile and stays")));
|
||||
@@ -289,7 +298,7 @@ pub enum Grays {
|
||||
impl Grays {
|
||||
/// The `grays` param of `cmd`: `"profile"` (default) or `"black"`.
|
||||
pub(crate) fn param(cmd: &str, p: &Value) -> Result<Self> {
|
||||
match str_param(p, "grays").map(str::to_ascii_lowercase).as_deref() {
|
||||
match text_param(p, "grays", cmd)?.map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("profile") => Ok(Grays::Profile),
|
||||
Some("black") => Ok(Grays::Black),
|
||||
Some(g) => Err(bad(cmd, format!("grays must be \"profile\" or \"black\", not `{g}`"))),
|
||||
@@ -302,18 +311,18 @@ impl Grays {
|
||||
/// colour through the colour settings.
|
||||
pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.documentColorMode";
|
||||
let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
|
||||
let mode = match p.get("mode").and_then(Value::as_str).map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("cmyk") => ColorMode::Cmyk,
|
||||
Some("rgb") => ColorMode::Rgb,
|
||||
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
|
||||
};
|
||||
let intent = intent_param(p, C)?;
|
||||
let grays = Grays::param(C, p)?;
|
||||
let convert = switch_or(p, "convert", true, C)?;
|
||||
// Choosing the mode the document already has (the checked menu item) is not an edit.
|
||||
if s.doc()?.doc.color_mode == mode {
|
||||
return Ok(json!({ "changed": 0 }));
|
||||
}
|
||||
let convert = bool_or(p, "convert", true);
|
||||
let mut changed = 0usize;
|
||||
s.edit("Document Color Mode", |d, _| {
|
||||
changed = set_color_mode(d, mode, convert, intent, grays);
|
||||
@@ -654,7 +663,7 @@ fn convert_color(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = cms::active();
|
||||
let intent = intent_param(p, C)?.unwrap_or(c.settings().intent);
|
||||
let lab = c.lab(&col);
|
||||
let (model, vals) = match str_param(p, "to").unwrap_or("rgb") {
|
||||
let (model, vals) = match text_param(p, "to", C)?.unwrap_or("rgb") {
|
||||
"rgb" => ("rgb", values(&c.convert(&col, Model::Rgb, intent))),
|
||||
"cmyk" => ("cmyk", values(&c.convert(&col, Model::Cmyk, intent))),
|
||||
"gray" | "grayscale" => ("gray", values(&c.convert(&col, Model::Gray, intent))),
|
||||
@@ -674,7 +683,11 @@ fn convert_color(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn gamut_check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut cols: Vec<Color> = vec![];
|
||||
if let Some(a) = p.get("colors").and_then(Value::as_array) {
|
||||
let colors = p.get("colors").filter(|v| !v.is_null());
|
||||
if let Some(v) = colors.filter(|v| !v.is_array()) {
|
||||
return Err(bad("color.gamutCheck", format!("`colors` must be an array of colors, not {v}")));
|
||||
}
|
||||
if let Some(a) = colors.and_then(Value::as_array) {
|
||||
for v in a {
|
||||
cols.push(color_value(v).ok_or_else(|| bad("color.gamutCheck", format!("bad color {v}")))?);
|
||||
}
|
||||
@@ -728,7 +741,7 @@ fn view_json(v: &proof::ProofView) -> Value {
|
||||
fn proof_setup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "view.proofSetup";
|
||||
let mut v = proof::view();
|
||||
if let Some(t) = str_param(p, "target") {
|
||||
if let Some(t) = text_param(p, "target", C)? {
|
||||
let t = ProofTarget::parse(t).ok_or_else(|| bad(C, format!("unknown proof target `{t}`")))?;
|
||||
if let ProofTarget::Cmyk(name) = &t
|
||||
&& !cms::profile(name).is_some_and(|k| k.kind == cms::ProfileKind::Cmyk)
|
||||
@@ -740,8 +753,8 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(i) = intent_param(p, C)? {
|
||||
v.setup.intent = i;
|
||||
}
|
||||
v.setup.simulate_paper = bool_or(p, "simulatePaper", v.setup.simulate_paper);
|
||||
if let Some(on) = p.get("proof").and_then(Value::as_bool) {
|
||||
v.setup.simulate_paper = switch_or(p, "simulatePaper", v.setup.simulate_paper, C)?;
|
||||
if let Some(on) = on_param(p, "proof", C)? {
|
||||
v.proof_colors = on;
|
||||
}
|
||||
proof::set_view(v.clone());
|
||||
@@ -749,9 +762,10 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(view_json(&v))
|
||||
}
|
||||
|
||||
fn toggle_view(s: &mut Session, p: &Value, f: impl FnOnce(&mut proof::ProofView, Option<bool>)) -> Result<Value> {
|
||||
fn toggle_view(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut proof::ProofView, Option<bool>)) -> Result<Value> {
|
||||
let on = on_param(p, "on", cmd)?;
|
||||
let mut v = proof::view();
|
||||
f(&mut v, p.get("on").and_then(Value::as_bool));
|
||||
f(&mut v, on);
|
||||
proof::set_view(v.clone());
|
||||
touch_all(s);
|
||||
Ok(view_json(&v))
|
||||
@@ -779,16 +793,23 @@ fn separations(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|d| proof::plates(&d.doc).into_iter().map(|p| p.name).collect())
|
||||
.unwrap_or_else(|| cms::PROCESS_PLATES.iter().map(|s| s.to_string()).collect());
|
||||
let check = |n: &str| if all.iter().any(|a| a == n) { Ok(n.to_string()) } else { Err(bad(C, format!("unknown plate `{n}`"))) };
|
||||
if let Some(on) = p.get("on").and_then(Value::as_bool) {
|
||||
if let Some(on) = on_param(p, "on", C)? {
|
||||
v.separations = if on { Some(v.separations.take().unwrap_or_else(|| all.clone())) } else { None };
|
||||
}
|
||||
if let Some(a) = p.get("plates").and_then(Value::as_array) {
|
||||
v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::<Result<Vec<_>>>()?);
|
||||
match p.get("plates") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Array(a)) if a.iter().all(Value::is_string) => {
|
||||
v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::<Result<Vec<_>>>()?);
|
||||
}
|
||||
Some(_) => {
|
||||
let key = "plates";
|
||||
return Err(bad(C, format!("`{key}` must be an array of these names: {}", all.join(", "))));
|
||||
}
|
||||
}
|
||||
if let Some(n) = str_param(p, "only") {
|
||||
if let Some(n) = text_param(p, "only", C)? {
|
||||
v.separations = Some(vec![check(n)?]);
|
||||
}
|
||||
if let Some(n) = str_param(p, "toggle") {
|
||||
if let Some(n) = text_param(p, "toggle", C)? {
|
||||
let n = check(n)?;
|
||||
let vis = v.separations.get_or_insert_with(|| all.clone());
|
||||
if let Some(i) = vis.iter().position(|x| *x == n) {
|
||||
@@ -804,8 +825,8 @@ fn separations(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn set_spot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.setSpot";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
||||
let spot = bool_or(p, "spot", true);
|
||||
let name = text_param(p, "name", C)?.ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
||||
let spot = switch_or(p, "spot", true, C)?;
|
||||
let sw = s.doc()?.doc.swatch(&name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?;
|
||||
if !matches!(sw.paint, Paint::Solid { .. }) {
|
||||
return Err(bad(C, "only solid-colour swatches can be spot colours"));
|
||||
|
||||
@@ -195,6 +195,64 @@ fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A dimension whose live link [`end_links`] ended, as it was before.
|
||||
pub(crate) struct EndedLink {
|
||||
id: NodeId,
|
||||
recipe: Dimension,
|
||||
art: Vec<Arc<Node>>,
|
||||
}
|
||||
|
||||
/// The dimensions under `roots` or holding one of them. A dimension can be listed more than once.
|
||||
pub(crate) fn reached(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut ids = Vec::new();
|
||||
for n in roots.iter().filter_map(|r| doc.node(*r)) {
|
||||
n.walk(&mut |c| {
|
||||
if c.extras().dimension.is_some() {
|
||||
ids.push(c.id);
|
||||
}
|
||||
});
|
||||
if doc.dimensions {
|
||||
let holders = doc.ancestry(n.id).unwrap_or_default();
|
||||
ids.extend(holders.into_iter().filter(|id| doc.node(*id).is_some_and(|h| h.extras().dimension.is_some())));
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
impl EndedLink {
|
||||
/// Is the art this dimension measures, changed from `before` to `after`, still art it can link to?
|
||||
pub(crate) fn source_kept(&self, before: &Document, after: &Document) -> bool {
|
||||
!self.recipe.source.as_ref().is_some_and(|s| s.topology_changed(before, after)) && self.recipe.clone().resolve(after).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it
|
||||
/// measured. A command that replaces art calls this before it changes anything, since replacing
|
||||
/// the art a dimension measures would remove the dimension, and then [`restore_unchanged`].
|
||||
pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec<EndedLink> {
|
||||
let mut ended = Vec::new();
|
||||
for id in reached(doc, roots) {
|
||||
let Some(n) = doc.node_mut(id) else { continue };
|
||||
let art = n.children().cloned().unwrap_or_default();
|
||||
if let Some(recipe) = n.extras_mut().dimension.take() {
|
||||
ended.push(EndedLink { id, recipe: *recipe, art });
|
||||
}
|
||||
}
|
||||
ended
|
||||
}
|
||||
|
||||
/// The dimensions in `ended` whose art is as it was are live again.
|
||||
pub(crate) fn restore_unchanged(doc: &mut Document, ended: Vec<EndedLink>) {
|
||||
for e in ended {
|
||||
if doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art)
|
||||
&& let Some(n) = doc.node_mut(e.id)
|
||||
{
|
||||
n.extras_mut().dimension = Some(Box::new(e.recipe));
|
||||
doc.dimensions = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called inside every edit, so dimensions and their undo history stay in step with the art they
|
||||
/// measure. Returns at once in documents without dimensions.
|
||||
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
|
||||
|
||||
@@ -14,7 +14,7 @@ pub(super) fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "need at least one point"));
|
||||
}
|
||||
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?);
|
||||
if let Some(id) = id_param(p, "id") {
|
||||
if let Some(id) = crate::cmd::object_param(p, "id", C)? {
|
||||
s.edit("Curvature", |d, sel| {
|
||||
*path_mut(d, id)? = PathData::single(sp);
|
||||
sel.set([id]);
|
||||
|
||||
@@ -9,9 +9,9 @@ use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::clipboard::SwatchChoices;
|
||||
use super::{
|
||||
CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, cmd, f64_or, f64_req,
|
||||
finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, index, ok,
|
||||
point_param, str_param, switch_or,
|
||||
CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, clock_date, cmd, date_param,
|
||||
f64_or, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param,
|
||||
index, ok, point_param, str_param, switch_or,
|
||||
};
|
||||
use crate::{Clipboard, EngineError, inspect};
|
||||
|
||||
@@ -116,6 +116,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
can_clear_history,
|
||||
clear_history
|
||||
),
|
||||
cmd!(
|
||||
"edit.newDocumentFromState",
|
||||
"New Document from State",
|
||||
[],
|
||||
None,
|
||||
"{index?: a row of the History panel (0: the oldest state kept; default: the current state), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches)} make a new document holding the art as it was at that state, and make it the active document (the History panel's New Document from State button). The new document is named with the next Untitled-N, has nothing selected and no steps to undo or redo, is marked unsaved and links to the same files. It is out of symbol, opacity-mask and pattern editing, with the art drawn in a pattern's tile stored in the pattern as object.pattern.done stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step, as edit.undo keeps it; `index` names the row as it was listed before that. An index that names no state is an error and changes nothing → {index: the new document's, title}",
|
||||
has_doc,
|
||||
new_document_from_state
|
||||
),
|
||||
cmd!(
|
||||
"edit.undoGroup.begin",
|
||||
"Begin Undo Group",
|
||||
@@ -139,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Cut",
|
||||
["Edit"],
|
||||
Some("Cmd+X"),
|
||||
"{} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)",
|
||||
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
cut
|
||||
),
|
||||
@@ -148,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy",
|
||||
["Edit"],
|
||||
Some("Cmd+C"),
|
||||
"{} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}",
|
||||
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
copy
|
||||
),
|
||||
@@ -606,6 +615,53 @@ fn clear_history(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "cleared": cleared }))
|
||||
}
|
||||
|
||||
/// History panel › New Document from State: a new document with the art of a state, and without
|
||||
/// the editing state that state may hold (opacity-mask or symbol editing, Puppet Warp pins, new art).
|
||||
/// Its links point at the files the source finds, which may be in the source's folder away from
|
||||
/// the links' paths. Later artboards in the two documents take their ids separately. A later save of the new document
|
||||
/// over the file its art was read from asks first, as a save of the source does.
|
||||
fn new_document_from_state(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.newDocumentFromState";
|
||||
let created = date_param(p, "created", C)?;
|
||||
let st = s.doc()?;
|
||||
// The row as the panel lists it now: keeping an edit in progress below changes the rows.
|
||||
let chosen = match p.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let state = v.as_u64().and_then(|row| st.history.state(&st.doc, index(row)));
|
||||
Some(state.ok_or_else(|| bad(C, format!("no history state {v}")))?.clone())
|
||||
}
|
||||
};
|
||||
settle_interaction(s)?;
|
||||
let st = s.doc()?;
|
||||
let mut doc = Document::clone(chosen.as_ref().unwrap_or(&st.doc));
|
||||
let (imported_from, import_losses, opened_by) = (st.imported_from.clone(), st.import_losses.clone(), st.opened_by.clone());
|
||||
if let Some(path) = st.path.as_deref() {
|
||||
// Files are looked for within the asking caller's roots, else within those of the caller that opened the source.
|
||||
let roots = crate::file_access::current().or_else(|| opened_by.clone());
|
||||
crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, path));
|
||||
}
|
||||
doc.drop_edit_modes();
|
||||
if doc.pattern_edit.is_some() {
|
||||
super::patterncmds::finish_edit(&mut doc, &mut vectorcraft_doc::Selection::default())?;
|
||||
}
|
||||
doc.puppet = None;
|
||||
doc.new_art.set(Arc::default());
|
||||
let retired = vectorcraft_doc::RetiredArtboardId::default();
|
||||
retired.raise(doc.retired_artboard_id.get());
|
||||
doc.retired_artboard_id = retired;
|
||||
doc.title = s.next_untitled();
|
||||
doc.metadata.created = clock_date(s, "created", created);
|
||||
doc.metadata.modified = None;
|
||||
let i = s.add_document(doc, None);
|
||||
let st = s.doc_mut()?;
|
||||
st.imported_from = imported_from;
|
||||
st.import_losses = import_losses;
|
||||
st.opened_by = st.opened_by.take().or(opened_by);
|
||||
st.mark_unsaved();
|
||||
Ok(json!({ "index": i, "title": st.title() }))
|
||||
}
|
||||
|
||||
/// `selection` for an undo (redo) step, sharing that of `like`, the step beside it, when the same.
|
||||
fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::HistoryEntry>) -> Arc<vectorcraft_doc::Selection> {
|
||||
match like {
|
||||
@@ -701,10 +757,15 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
|
||||
Ok((direct, roots))
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// The params edit.copy and edit.cut pass on to `artboard.copy` and `artboard.cut`.
|
||||
fn artboard_clipboard_params(p: &Value) -> Value {
|
||||
p.get("lockedAndHidden").map_or_else(|| json!({}), |v| json!({ "lockedAndHidden": v }))
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The Artboard tool copies its artboard (and the art on it).
|
||||
if s.tool_id() == "artboard" {
|
||||
return super::panelcmds::artboard_copy(s, &json!({}), false);
|
||||
return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false);
|
||||
}
|
||||
let (direct, targets) = copy_cut_targets(s)?;
|
||||
let st = s.doc()?;
|
||||
@@ -744,7 +805,7 @@ fn copy(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.tool_id() == "artboard" {
|
||||
return super::panelcmds::artboard_copy(s, &json!({}), true);
|
||||
return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true);
|
||||
}
|
||||
// Ruler guides selected on their own: Copy takes them, Clear removes them.
|
||||
if s.doc()?.selection.is_empty() {
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effect Options",
|
||||
[],
|
||||
None,
|
||||
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}",
|
||||
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}",
|
||||
has_doc,
|
||||
set_params
|
||||
),
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Appearance",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
can_expand,
|
||||
expand_appearance
|
||||
),
|
||||
@@ -222,6 +222,16 @@ pub(crate) const ONE_3D: &str = "An object takes one 3D effect. Edit the existin
|
||||
pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.apply";
|
||||
let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?;
|
||||
// An effect given as `id` names no objects: the targets are read with that `id` as null.
|
||||
let effect_as_id;
|
||||
let p = if str_param(p, "effect").is_none() {
|
||||
let mut q = p.clone();
|
||||
q["id"] = Value::Null;
|
||||
effect_as_id = q;
|
||||
&effect_as_id
|
||||
} else {
|
||||
p
|
||||
};
|
||||
let mut params = p.get("params").cloned().unwrap_or(Value::Null);
|
||||
if !params.is_null() && !params.is_object() {
|
||||
return Err(bad(C, "`params` must be an object"));
|
||||
@@ -422,10 +432,28 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
|
||||
return vec![];
|
||||
};
|
||||
let Ok(targets) = effect_targets(s, p, item, index, C) else { return vec![] };
|
||||
let effect = |&(id, at, index): &(NodeId, Option<usize>, usize)| Some(st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id.clone());
|
||||
// A 3D effect whose type `threeDType` changes takes the new effect's parameters.
|
||||
let kind = three_d_type(p, C).ok().flatten();
|
||||
let effect = |&(id, at, index): &(NodeId, Option<usize>, usize)| {
|
||||
let id = &st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id;
|
||||
Some(kind.filter(|_| effects::is_three_d(id)).map_or_else(|| id.clone(), str::to_string))
|
||||
};
|
||||
targets.iter().filter_map(effect).collect()
|
||||
}
|
||||
|
||||
/// The effect `threeDType` in `p` turns a 3D effect into ([`effects::THREE_D_TYPES`]), when given.
|
||||
fn three_d_type(p: &Value, cmd: &str) -> Result<Option<&'static str>> {
|
||||
let key = "threeDType";
|
||||
let Some(v) = p.get(key) else { return Ok(None) };
|
||||
match v.as_str().and_then(|t| effects::THREE_D_TYPES.iter().find(|(name, _)| *name == t)) {
|
||||
Some((_, id)) => Ok(Some(id)),
|
||||
None => {
|
||||
let names: Vec<&str> = effects::THREE_D_TYPES.iter().map(|(name, _)| *name).collect();
|
||||
Err(bad(cmd, format!("`{key}` must be one of {}", names.join(", "))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.lightingPreset";
|
||||
let patch = str_param(p, "preset")
|
||||
@@ -450,8 +478,9 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// `effect.setParams`: merges `params` into the effect at `index` of the targets' active item, and
|
||||
/// sets its Warp style (`warpStyle`) or visibility (`visible`), as one undo step. They are checked
|
||||
/// as `effect.apply` checks them, and a visible 3D effect must still fit. → `{ids}`.
|
||||
/// sets its Warp style (`warpStyle`), its 3D type (`threeDType`) or visibility (`visible`), as one
|
||||
/// undo step. They are checked as `effect.apply` checks them, and a visible 3D effect must still
|
||||
/// fit. → `{ids}`.
|
||||
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.setParams";
|
||||
let index = index_param(p, "index", C)?;
|
||||
@@ -459,6 +488,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !params.is_null() && !params.is_object() {
|
||||
return Err(bad(C, "`params` must be an object"));
|
||||
}
|
||||
let kind = three_d_type(p, C)?;
|
||||
let warp_id = p
|
||||
.get("warpStyle")
|
||||
.map(|v| -> Result<String> {
|
||||
@@ -476,21 +506,41 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if edited.iter().any(|e| texture_load_effect(e)) {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
for (id, target, index) in effect_targets(s, p, item, index, C)? {
|
||||
let targets = effect_targets(s, p, item, index, C)?;
|
||||
// The type each 3D effect already has, with nothing else to change, is no edit (and is not
|
||||
// checked again).
|
||||
let nothing_else = params.as_object().is_none_or(serde_json::Map::is_empty) && visible.is_none() && warp_id.is_none();
|
||||
if let Some(to) = kind
|
||||
&& nothing_else
|
||||
{
|
||||
let d = &s.doc()?.doc;
|
||||
// The effects edited: a target without an effect at `index` is left as it is.
|
||||
let ids: Vec<&str> =
|
||||
targets.iter().filter_map(|&(id, at, index)| d.node(id)?.appearance.effects_at(at)?.get(index).map(|e| e.id.as_str())).collect();
|
||||
if !ids.is_empty() && ids.iter().all(|id| *id == to) {
|
||||
return Ok(json!({ "ids": [] }));
|
||||
}
|
||||
}
|
||||
for &(id, target, index) in &targets {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index));
|
||||
if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) {
|
||||
return Err(bad(C, "warpStyle can only edit an applied Warp effect"));
|
||||
}
|
||||
if kind.is_some() && effect.is_some_and(|e| !effects::is_three_d(&e.id)) {
|
||||
return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0)));
|
||||
}
|
||||
if let Some(e) = effect
|
||||
&& effects::is_three_d(&e.id)
|
||||
&& visible != Some(false)
|
||||
{
|
||||
let mut prospective = effects::merged_params(&e.id, &e.params);
|
||||
if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(&e.id, ¶ms)) {
|
||||
let to = kind.unwrap_or(&e.id);
|
||||
let mut prospective =
|
||||
if to == e.id { effects::merged_params(&e.id, &e.params) } else { effects::three_d_params_as(&e.id, &e.params, to) };
|
||||
if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(to, ¶ms)) {
|
||||
cur.extend(new);
|
||||
}
|
||||
effects::validate_three_d(n, &e.id, &prospective).map_err(|e| bad(C, e))?;
|
||||
effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), index, |fx, index| {
|
||||
@@ -498,6 +548,12 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(id) = &warp_id {
|
||||
e.id.clone_from(id);
|
||||
}
|
||||
if let Some(to) = kind
|
||||
&& e.id != to
|
||||
{
|
||||
e.params = effects::three_d_params_as(&e.id, &e.params, to);
|
||||
e.id = to.to_string();
|
||||
}
|
||||
if let (Value::Object(new), cur) = (effects::upgrade_params(&e.id, ¶ms), &mut e.params) {
|
||||
if !cur.is_object() {
|
||||
*cur = effects::merged_params(&e.id, &Value::Null);
|
||||
@@ -828,12 +884,17 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let ids = s.edit("Expand Appearance", |d, _| {
|
||||
let before = d.clone();
|
||||
let mut ended = super::dimension::end_links(d, &roots);
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let mut out = vec![];
|
||||
let threaded = super::threads::frames(d);
|
||||
for id in &roots {
|
||||
expand_node(d, *id, &brushes, &mut out);
|
||||
}
|
||||
// A dimension whose source was replaced stays plain: its link no longer fits that art.
|
||||
ended.retain(|e| e.source_kept(&before, d));
|
||||
super::dimension::restore_unchanged(d, ended);
|
||||
// Outlined frames of threaded text leave their threads with the text they show; type that
|
||||
// stays type under a new id takes its frame's place.
|
||||
super::threads::leave_threads(d, &threaded);
|
||||
@@ -854,5 +915,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod lighting_presets_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod three_d_type_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn setup(effect: &str, params: Value) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":400})).unwrap();
|
||||
let id = NodeId(s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"color":"#338ad6"})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect":effect,"item":null,"params":params})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn effect(s: &Session, id: NodeId) -> (String, Value) {
|
||||
let e = &s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0];
|
||||
(e.id.clone(), e.params.clone())
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_type_turns_the_effect_in_place_keeping_shared_settings_in_one_undo_step() {
|
||||
let types = [("plane", "threeD.rotate"), ("extrude", "threeD.extrude"), ("revolve", "threeD.revolve"), ("inflate", "threeD.inflate")];
|
||||
for (from_type, from) in types {
|
||||
for (to_type, to) in types {
|
||||
if from == to {
|
||||
continue;
|
||||
}
|
||||
let (mut s, id) = setup(from, json!({"rotationX": 12.0, "rotationY": -30.0, "rotationZ": 5.0, "ambient": 40.0, "lightIntensity": 55.0}));
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = undo_len(&s);
|
||||
let p = json!({"index": 0, "item": null, "threeDType": to_type});
|
||||
s.check_params("effect.setParams", &p).unwrap();
|
||||
s.execute("effect.setParams", &p).unwrap();
|
||||
let (now, params) = effect(&s, id);
|
||||
assert_eq!(now, to, "{from_type} → {to_type}");
|
||||
assert_eq!(params["rotationX"], 12.0, "{from_type} → {to_type}");
|
||||
assert_eq!(params["rotationY"], -30.0);
|
||||
assert_eq!(params["ambient"], 40.0);
|
||||
assert_eq!(params["lightIntensity"], 55.0);
|
||||
// Settings only the new effect takes start at its defaults; others are dropped.
|
||||
let defaults = vectorcraft_render::effects::default_params(to).unwrap();
|
||||
for key in params.as_object().unwrap().keys() {
|
||||
assert!(defaults.get(key).is_some(), "{from_type} → {to_type} kept `{key}`");
|
||||
}
|
||||
if to == "threeD.extrude" {
|
||||
assert_eq!(params["bevel"], defaults["bevel"]);
|
||||
}
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_type_carries_depth_between_extrude_and_inflate_and_merges_params_after() {
|
||||
let (mut s, id) = setup("threeD.extrude", json!({"depth": 80.0, "twist": 30.0, "bevel": "classic"}));
|
||||
s.execute("effect.setParams", &json!({"index": 0, "threeDType": "inflate", "params": {"volume": 70.0}})).unwrap();
|
||||
let (now, params) = effect(&s, id);
|
||||
assert_eq!(now, "threeD.inflate");
|
||||
assert_eq!(params["depth"], 80.0);
|
||||
assert_eq!(params["twist"], 30.0);
|
||||
assert_eq!(params["volume"], 70.0);
|
||||
assert!(params.get("bevel").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_angles_outside_half_a_turn_wrap_when_the_type_leaves_revolve() {
|
||||
let (mut s, id) = setup("threeD.revolve", json!({"rotationX": 270.0, "rotationY": -200.0}));
|
||||
s.execute("effect.setParams", &json!({"index": 0, "threeDType": "plane"})).unwrap();
|
||||
let (_, params) = effect(&s, id);
|
||||
assert_eq!(params["rotationX"], -90.0);
|
||||
assert_eq!(params["rotationY"], 160.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_current_type_alone_changes_nothing_and_records_no_undo_step() {
|
||||
let (mut s, id) = setup("threeD.extrude", json!({"depth": 80.0}));
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = undo_len(&s);
|
||||
let r = s.execute("effect.setParams", &json!({"index": 0, "threeDType": "extrude"})).unwrap();
|
||||
assert_eq!(r["ids"], json!([]));
|
||||
assert_eq!(undo_len(&s), history);
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
// With other changes the edit runs as usual.
|
||||
s.execute("effect.setParams", &json!({"index": 0, "threeDType": "extrude", "params": {"depth": 90.0}})).unwrap();
|
||||
assert_eq!(effect(&s, id).1["depth"], 90.0);
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_current_type_with_an_object_without_effects_selected_too_records_no_undo_step() {
|
||||
let (mut s, a) = setup("threeD.extrude", json!({}));
|
||||
let b = NodeId(s.execute("path.create", &json!({"d":"M250 100L350 100L350 200L250 200Z"})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = undo_len(&s);
|
||||
s.execute("effect.setParams", &json!({"index": 0, "item": null, "aligned": true, "threeDType": "extrude"})).unwrap();
|
||||
assert_eq!(undo_len(&s), history);
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_type_rejects_unknown_types_and_effects_that_are_not_3d() {
|
||||
let (mut s, _) = setup("threeD.extrude", json!({}));
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for t in [json!("cube"), json!(""), json!(null), json!(2), json!("Plane"), json!("plane/../x")] {
|
||||
let p = json!({"index": 0, "threeDType": t});
|
||||
let err = s.execute("effect.setParams", &p).unwrap_err().to_string();
|
||||
assert!(err.contains("must be one of plane, extrude, revolve, inflate"), "{t}: {err}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "item": null})).unwrap();
|
||||
let err = s.execute("effect.setParams", &json!({"index": 1, "threeDType": "plane"})).unwrap_err().to_string();
|
||||
assert!(err.contains("needs a 3D effect at index 1"), "{err}");
|
||||
let err = s.execute("effect.setParams", &json!({"index": 5, "threeDType": "plane"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no effect at index 5"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_type_turns_every_selected_object_in_one_undo_step() {
|
||||
let (mut s, a) = setup("threeD.rotate", json!({}));
|
||||
let b = NodeId(s.execute("path.create", &json!({"d":"M250 100L350 100L350 200L250 200Z"})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"color":"#d63333"})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect":"threeD.rotate","item":null})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let history = undo_len(&s);
|
||||
s.execute("effect.setParams", &json!({"index": 0, "item": null, "aligned": true, "threeDType": "extrude"})).unwrap();
|
||||
assert_eq!(effect(&s, a).0, "threeD.extrude");
|
||||
assert_eq!(effect(&s, b).0, "threeD.extrude");
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_for_the_new_type_pass_the_parameter_check() {
|
||||
let (s, _) = setup("threeD.rotate", json!({}));
|
||||
// `depth` is an Extrude & Bevel setting, checked against the effect the type makes.
|
||||
s.check_params("effect.setParams", &json!({"index": 0, "threeDType": "extrude", "params": {"depth": 20.0}})).unwrap();
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
//! as Break Link to Symbol places it. A linear or radial gradient fill becomes a
|
||||
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
|
||||
//! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip
|
||||
//! group shaped like the object. Create Gradient Mesh shares the sampling.
|
||||
//! group shaped like the object. Create Gradient Mesh shares the sampling. A dimension whose art
|
||||
//! Expand changes becomes a plain group of that art.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
@@ -19,6 +20,7 @@ use vectorcraft_doc::pattern::{PatternDef, place_art};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes};
|
||||
|
||||
use super::dimension::EndedLink;
|
||||
use super::edit::selected_roots;
|
||||
use super::menucmds::{run_raw, squash};
|
||||
use super::object::as_clipping_path;
|
||||
@@ -41,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -239,8 +241,12 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let stroke = bool_or(p, "stroke", true);
|
||||
let to = GradientTo::parse(p)?;
|
||||
let from = s.doc()?.history.undo.len();
|
||||
let rev0 = s.doc()?.revision;
|
||||
let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
// Before anything expands: outlining the art a dimension measures would remove the dimension.
|
||||
let before = s.doc()?.doc.clone();
|
||||
let mut ended = vec![];
|
||||
end_dimension_links(s, &mut ended)?;
|
||||
let rev0 = s.doc()?.revision;
|
||||
// Placed documents first: their art (type, strokes, gradients) expands below.
|
||||
if object && e.placed {
|
||||
let roots = selected_roots(s)?;
|
||||
@@ -286,6 +292,8 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, _| super::patterncmds::expand_repeats(d, &roots).map(|_| ()))?;
|
||||
}
|
||||
// The dimensions in the art those steps made.
|
||||
end_dimension_links(s, &mut ended)?;
|
||||
if object && e.effects {
|
||||
// Effects that Expand Appearance can't expand are left as they are. When it refuses to
|
||||
// leave a process color on a grayscale image with transparency, Expand fails as a whole.
|
||||
@@ -331,10 +339,28 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc()?.revision == rev0 {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
}
|
||||
if !ended.is_empty() {
|
||||
// A dimension whose source Expand replaced stays plain: its link no longer fits that art.
|
||||
let after = s.doc()?.doc.clone();
|
||||
ended.retain(|e| e.source_kept(&before, &after));
|
||||
s.edit("Expand", |d, _| {
|
||||
super::dimension::restore_unchanged(d, std::mem::take(&mut ended));
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
squash(s, from, "Expand");
|
||||
Ok(json!({ "ids": s.doc()?.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Ends the live link of each dimension under or holding the selection, adding it to `ended`.
|
||||
fn end_dimension_links(s: &mut Session, ended: &mut Vec<EndedLink>) -> Result<()> {
|
||||
let roots = selected_roots(s)?;
|
||||
if !super::dimension::reached(&s.doc()?.doc, &roots).is_empty() {
|
||||
ended.extend(s.edit("Expand", |d, _| Ok(super::dimension::end_links(d, &roots)))?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drop_live(n: &mut Node) {
|
||||
if let NodeKind::Path { live, .. } = &mut n.kind {
|
||||
*live = None;
|
||||
|
||||
@@ -9,7 +9,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
|
||||
@@ -373,7 +373,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
Ok(o) => super::pdfimport::import(bytes, o, opts, format.id != "pdf"),
|
||||
Err(e) => Err(err(e)),
|
||||
};
|
||||
let read = restore_or_import(editing, || {
|
||||
let mut read = restore_or_import(editing, || {
|
||||
// Type all from the text document and nothing that needs the PDF part: the layers
|
||||
// alone, the PDF part not imported. Else what was read of the layers is kept for
|
||||
// reading them with the PDF part.
|
||||
@@ -440,6 +440,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
if !read.2 {
|
||||
file_info = opened.as_ref().ok().map(|o| o.file_info());
|
||||
}
|
||||
// A restored document opens on the artboard that the editing copy beside it names as
|
||||
// active, as a file read from that copy does (the restored document never holds one).
|
||||
if read.2
|
||||
&& let Some(private) = opened.as_ref().ok().and_then(|o| o.illustrator_data())
|
||||
{
|
||||
read.0.opened_artboard = vectorcraft_eps::ai_opened_artboard(&private, &read.0);
|
||||
}
|
||||
read
|
||||
}
|
||||
"dxf" => {
|
||||
|
||||
@@ -109,7 +109,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
|
||||
name: "editingData",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards and which one is active, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, and linked images as placed files with a preview, with type, other patterns, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)",
|
||||
description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards and which one is active, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, linked images as placed files with a preview, point type, area type and type on a path as text, and symbols and their instances, with other type, other patterns, other clipping objects, other symbol instances and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "aiVersion",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ use vectorcraft_doc::{ColorMode, Document};
|
||||
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs};
|
||||
|
||||
use super::super::colormgmt::Grays;
|
||||
use super::super::{Result, Session, bad, f64_or, parse_range, str_param};
|
||||
use super::super::{Result, Session, bad, check_numbers, number_or, parse_range, text_param};
|
||||
use super::{err, source};
|
||||
|
||||
/// The `document.open` options besides the file. The PDF ones (pages, box, password, text,
|
||||
@@ -75,16 +75,17 @@ impl LoadOptions {
|
||||
Some(Value::Number(n)) => Some(n.to_string()),
|
||||
Some(v) => return Err(bad(cmd, format!("pages must be a range such as \"2-3\" or a page number, not {v}"))),
|
||||
};
|
||||
let crop = choice(cmd, "cropTo", str_param(p, "cropTo").or_else(|| str_param(p, "crop")))?;
|
||||
let password = str_param(p, "password").filter(|s| !s.is_empty()).map(str::to_string);
|
||||
let color_mode = match str_param(p, "colorMode").map(str::to_ascii_lowercase).as_deref() {
|
||||
let (crop_to, crop_alias) = (text_param(p, "cropTo", cmd)?, text_param(p, "crop", cmd)?);
|
||||
let crop = choice(cmd, "cropTo", crop_to.or(crop_alias))?;
|
||||
let password = text_param(p, "password", cmd)?.filter(|s| !s.is_empty()).map(str::to_string);
|
||||
let color_mode = match text_param(p, "colorMode", cmd)?.map(str::to_ascii_lowercase).as_deref() {
|
||||
None => None,
|
||||
Some("rgb") => Some(ColorMode::Rgb),
|
||||
Some("cmyk") => Some(ColorMode::Cmyk),
|
||||
Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))),
|
||||
};
|
||||
let grays = Grays::param(cmd, p)?;
|
||||
let text_as = choice(cmd, "textAs", str_param(p, "textAs"))?;
|
||||
let text_as = choice(cmd, "textAs", text_param(p, "textAs", cmd)?)?;
|
||||
let layers = match p.get("layers").filter(|v| !v.is_null()) {
|
||||
None => true,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
|
||||
@@ -172,6 +173,7 @@ const INFO: &str = "document.pdfInfo";
|
||||
pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = source(p, INFO)?.bytes;
|
||||
let o = LoadOptions::from_params(INFO, p)?;
|
||||
check_numbers(p, &["thumbnailSize"], INFO)?;
|
||||
// Programs whose header is only `%!` are PostScript too, as `clipboard.importEps` reads them.
|
||||
if bytes.starts_with(b"%!") || vectorcraft_pdf::is_postscript(&bytes) {
|
||||
return postscript_info(&bytes, p);
|
||||
@@ -229,7 +231,7 @@ fn postscript_info(bytes: &[u8], p: &Value) -> Result<Value> {
|
||||
/// A PNG of `doc`'s first artboard, `thumbnailSize` px (default 160, from 16 to 1024) on its longer
|
||||
/// side, base64.
|
||||
fn thumbnail(doc: &Document, p: &Value) -> Result<String> {
|
||||
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
|
||||
let size = number_or(p, "thumbnailSize", 160.0, INFO)?.clamp(16.0, 1024.0);
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
|
||||
let png = super::encode(doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
|
||||
Ok(vectorcraft_format::base64_encode(&png))
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use vectorcraft_doc::cancel::{self, CancelToken, ProgressSink};
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct Progress {
|
||||
total: AtomicU64,
|
||||
/// The steps are files (Export for Screens), not artboards.
|
||||
files: bool,
|
||||
/// The last report came from a plug-in ([`vectorcraft_plugins::host::PROGRESS_STEP`]): its
|
||||
/// steps are its own, shown as a share of the work.
|
||||
share: AtomicBool,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
@@ -43,6 +46,11 @@ impl Progress {
|
||||
self.files
|
||||
}
|
||||
|
||||
/// Are the steps a plug-in's own, which only a share of the work done describes?
|
||||
pub fn counts_share(&self) -> bool {
|
||||
self.share.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// `(steps done, steps in all)`; `total` is 0 while the export can't say (one file made in one go).
|
||||
pub fn counts(&self) -> (u64, u64) {
|
||||
let total = self.total.load(Ordering::Relaxed);
|
||||
@@ -68,7 +76,8 @@ impl Progress {
|
||||
/// Run `f` with `progress`'s token in effect on this thread and the steps reported to it.
|
||||
pub fn scope<R>(progress: &Arc<Progress>, f: impl FnOnce() -> R) -> R {
|
||||
let to = Arc::clone(progress);
|
||||
let sink: ProgressSink = Rc::new(move |done, total, _| {
|
||||
let sink: ProgressSink = Rc::new(move |done, total, what| {
|
||||
to.share.store(what == vectorcraft_plugins::host::PROGRESS_STEP, Ordering::Relaxed);
|
||||
to.total.store(total, Ordering::Relaxed);
|
||||
to.done.store(done, Ordering::Relaxed);
|
||||
});
|
||||
@@ -94,4 +103,14 @@ mod tests {
|
||||
assert!(matches!(r, Err(crate::EngineError::Cancelled)), "{:?}", r.err());
|
||||
assert!(!cancel::is_cancelled(), "the scope ended");
|
||||
}
|
||||
|
||||
/// A plug-in's reports count as a share of the work, not as artboards or files.
|
||||
#[test]
|
||||
fn a_plugin_s_reports_count_as_a_share_of_the_work() {
|
||||
let p = Progress::new();
|
||||
scope(&p, || cancel::progress(1, 4, "artboard"));
|
||||
assert_eq!((p.counts(), p.counts_share()), ((1, 4), false));
|
||||
scope(&p, || cancel::progress(3, 8, vectorcraft_plugins::host::PROGRESS_STEP));
|
||||
assert_eq!((p.counts(), p.counts_share()), ((3, 8), true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1072,7 +1072,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?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <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?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -1019,11 +1019,14 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) ->
|
||||
let mut out = Output::default();
|
||||
// The encoders' warnings for every file, each once.
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
// Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are
|
||||
// written on a page of their own bounds, which it doesn't grow.
|
||||
let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none();
|
||||
let mut write = |doc: &Document, format: &str, options: &Value| -> Result<Vec<u8>> {
|
||||
// The steps are the files: an encoder's own steps (artboards, pages) are not reported.
|
||||
let (bytes, notes) = vectorcraft_doc::cancel::quiet(|| encode_with_warnings(doc, format, options))?;
|
||||
for w in notes {
|
||||
if !warnings.contains(&w) {
|
||||
if !(bleed_on_page && w == super::pdf::BLEED_LEFT_OUT) && !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,9 @@ fn templates_and_libraries_cannot_silently_extract_preview_content() {
|
||||
let original = s.doc().unwrap().doc.clone();
|
||||
for name in ["test.af", "renamed.svg"] {
|
||||
let p = json!({"name":name, "dataBase64":vectorcraft_format::base64_encode(&file(&png()))});
|
||||
for cmd in ["file.newFromTemplate", "swatch.library.load", "graphicStyle.loadLibrary"] {
|
||||
for cmd in
|
||||
["file.newFromTemplate", "swatch.library.load", "graphicStyle.loadLibrary", "charStyle.load", "paraStyle.load", "textStyle.loadAll"]
|
||||
{
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("File › Open"), "{cmd}: {e}");
|
||||
}
|
||||
|
||||
@@ -148,6 +148,53 @@ fn an_ai_file_opens_with_its_active_artboard() {
|
||||
assert_eq!(d.opened_artboard, Some(d.artboards[1].id), "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` or `.ait` file VectorCraft wrote reopens, restored from the document it carries, on the
|
||||
/// artboard its editing copy names as active; without the copy, on the first artboard.
|
||||
#[test]
|
||||
fn a_restored_ai_file_opens_on_the_artboard_active_when_saved() {
|
||||
let mut doc = vectorcraft_doc::Document::new(100.0, 100.0);
|
||||
let first = doc.artboards[0].clone();
|
||||
for (i, id) in [(1.0, 7), (2.0, 9)] {
|
||||
let x0 = i * 150.0;
|
||||
doc.artboards.push(vectorcraft_doc::Artboard { id, rect: vectorcraft_geom::Rect::new(x0, 0.0, x0 + 100.0, 100.0), ..first.clone() });
|
||||
}
|
||||
let mut s = Session::new();
|
||||
// The view's artboard, one the document doesn't have, none, and the first.
|
||||
for (active, opened, reopened) in
|
||||
[(Some(7), None, Some(7)), (Some(99), Some(9), Some(9)), (None, Some(9), Some(9)), (Some(first.id), Some(9), None)]
|
||||
{
|
||||
for (cmd, format) in [("document.save", "ai"), ("file.saveAsTemplate", "ait")] {
|
||||
let mut d = doc.clone();
|
||||
d.opened_artboard = opened;
|
||||
s.add_document(d, None);
|
||||
s.doc_mut().unwrap().active_artboard = active;
|
||||
let saved = s.execute(cmd, &json!({"format": format})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let r = open(&mut s, &format!("again.{format}"), &bytes, json!({})).unwrap();
|
||||
assert_eq!(r["restored"], json!(true), "{r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.artboards.iter().map(|a| a.id).collect::<Vec<_>>(), [first.id, 7, 9]);
|
||||
assert_eq!(d.opened_artboard, reopened, "{format} {active:?} {opened:?}");
|
||||
}
|
||||
}
|
||||
// A file saved without its PDF part still has the editing copy.
|
||||
s.add_document(doc.clone(), None);
|
||||
s.doc_mut().unwrap().active_artboard = Some(7);
|
||||
let saved = s.execute("document.save", &json!({"format": "ai", "options": {"pdfCompatible": false}})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap();
|
||||
assert_eq!(r["restored"], json!(true), "{r}");
|
||||
assert_eq!(s.doc().unwrap().doc.opened_artboard, Some(7), "{r}");
|
||||
// Without the editing copy, the file reopens on the first artboard.
|
||||
s.add_document(doc.clone(), None);
|
||||
s.doc_mut().unwrap().active_artboard = Some(7);
|
||||
let saved = s.execute("document.save", &json!({"format": "ai", "options": {"editingData": false}})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap();
|
||||
assert_eq!(r["restored"], json!(true), "{r}");
|
||||
assert_eq!(s.doc().unwrap().doc.opened_artboard, None, "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` save names the view's active artboard in its editing copy, else the one the document
|
||||
/// opened on, else the first; the file read from that copy opens on it.
|
||||
#[test]
|
||||
|
||||
@@ -488,7 +488,7 @@ fn an_ai_file_opens_from_its_vectorcraft_document_when_the_editing_copy_differs(
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "ids": [id]})).unwrap();
|
||||
let r = s.execute("document.export", &json!({"format": "ai"})).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("live effects"), "{r}");
|
||||
let bytes = b64(&r);
|
||||
|
||||
@@ -147,6 +147,76 @@ fn load_options_read_pages_crop_and_password() {
|
||||
assert_eq!(LoadOptions::from_params("x", &Value::Null).unwrap(), LoadOptions::default());
|
||||
}
|
||||
|
||||
/// `cropTo` (and `crop`), `password`, `colorMode` and `textAs` given as anything but a string are an
|
||||
/// error naming the param: `document.open` opens nothing and `file.place` places nothing and records
|
||||
/// no undo step. Null is the same as leaving the param out.
|
||||
#[test]
|
||||
fn pdf_options_of_the_wrong_type_are_refused() {
|
||||
let bytes = three_pages();
|
||||
let wrong = [
|
||||
("cropTo", json!(5)),
|
||||
("crop", json!(true)),
|
||||
("password", json!(123)),
|
||||
("colorMode", json!(5)),
|
||||
("textAs", json!(true)),
|
||||
("textAs", json!(["outlines"])),
|
||||
];
|
||||
let one = |key: &str, v: &Value| {
|
||||
let mut p = json!({});
|
||||
p[key] = v.clone();
|
||||
p
|
||||
};
|
||||
let mut s = Session::new();
|
||||
for (key, v) in &wrong {
|
||||
let e = open(&mut s, &bytes, one(key, v)).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("`{key}` must be a string")), "{key}: {e}");
|
||||
assert!(s.active().is_none(), "{key}: nothing opened");
|
||||
}
|
||||
// A wrong `cropTo` is refused even when `crop` is a box, and a wrong `crop` when `cropTo` is.
|
||||
let e = open(&mut s, &bytes, json!({"cropTo": 2, "crop": "art"})).unwrap_err().to_string();
|
||||
assert!(e.contains("`cropTo` must be a string"), "{e}");
|
||||
let e = open(&mut s, &bytes, json!({"cropTo": "art", "crop": 2})).unwrap_err().to_string();
|
||||
assert!(e.contains("`crop` must be a string"), "{e}");
|
||||
assert!(s.active().is_none(), "nothing opened");
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let nodes = s.doc().unwrap().doc.node_count();
|
||||
for (key, v) in &wrong {
|
||||
let mut p = json!({"name": "pages.pdf", "dataBase64": b64(&bytes)});
|
||||
p[*key] = v.clone();
|
||||
let e = s.execute("file.place", &p).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("`{key}` must be a string")), "{key}: {e}");
|
||||
assert_eq!((s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.node_count()), (undo, nodes), "{key}: nothing placed");
|
||||
}
|
||||
// Null is left out; valid strings read as before.
|
||||
let nulls = json!({"cropTo": null, "crop": null, "password": null, "colorMode": null, "textAs": null});
|
||||
assert_eq!(LoadOptions::from_params("x", &nulls).unwrap(), LoadOptions::default());
|
||||
let o = LoadOptions::from_params("x", &json!({"cropTo": null, "crop": "Bleed", "colorMode": "CMYK", "textAs": "outlines"})).unwrap();
|
||||
assert_eq!(
|
||||
(o.crop, o.color_mode, o.text_as),
|
||||
(vectorcraft_pdf::CropTo::Bleed, Some(vectorcraft_doc::ColorMode::Cmyk), vectorcraft_pdf::TextAs::Outlines)
|
||||
);
|
||||
}
|
||||
|
||||
/// `document.pdfInfo`'s `thumbnailSize` given as anything but a number is an error, with or without
|
||||
/// a `thumbnail`; null is 160 px.
|
||||
#[test]
|
||||
fn pdf_info_refuses_a_thumbnail_size_that_is_not_a_number() {
|
||||
let bytes = three_pages();
|
||||
let mut s = Session::new();
|
||||
for v in [json!("100"), json!(true)] {
|
||||
for p in [json!({"dataBase64": b64(&bytes), "thumbnail": 1, "thumbnailSize": v}), json!({"dataBase64": b64(&bytes), "thumbnailSize": v})] {
|
||||
let e = s.execute("document.pdfInfo", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("`thumbnailSize` must be a number"), "{v}: {e}");
|
||||
}
|
||||
}
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&bytes), "colorMode": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("`colorMode` must be a string"), "{e}");
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&bytes), "thumbnail": 1, "thumbnailSize": null})).unwrap();
|
||||
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(image::load_from_memory(&png).unwrap().width(), 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_postscript_ai_file_says_it_cannot_be_opened_yet() {
|
||||
// PostScript opens through the EPS reader now (tests_epsimport.rs): a program it can't read,
|
||||
|
||||
@@ -90,6 +90,55 @@ fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF/X file without the bleed the document sets exports as asked and warns; the presets and
|
||||
/// options that take the document's bleed, and files that aren't PDF/X files, don't warn.
|
||||
#[test]
|
||||
fn a_pdfx_file_that_leaves_out_the_document_bleed_warns() {
|
||||
let left_out = |w: &Vec<String>| w.iter().any(|w| w == super::pdf::BLEED_LEFT_OUT);
|
||||
let mut s = session();
|
||||
s.execute("document.setup", &json!({"bleed": 6})).unwrap();
|
||||
for standard in ["pdfX1a", "pdfX3", "pdfX4"] {
|
||||
let (_, warnings) = pdf(&mut s, json!({"standard": standard}));
|
||||
// First: the status bar shows the first warning, and PDF/X-1a and PDF/X-3 add the flattening note.
|
||||
assert_eq!(warnings.first().map(String::as_str), Some(super::pdf::BLEED_LEFT_OUT), "{standard}: {warnings:?}");
|
||||
// The page has no bleed, as asked.
|
||||
let v = s.execute("document.exportPdf", &json!({"standard": standard})).unwrap();
|
||||
let info = s.execute("document.pdfInfo", &json!({"name": "x.pdf", "dataBase64": v["dataBase64"]})).unwrap();
|
||||
let boxes = &info["pageInfo"][0]["boxes"];
|
||||
assert!(boxes["media"].is_array() && boxes["media"] == boxes["trim"] && boxes["media"] == boxes["bleed"], "{standard}: {info}");
|
||||
}
|
||||
let v = s.execute("document.export", &json!({"format": "pdf", "standard": "pdfX4"})).unwrap();
|
||||
assert!(v["warnings"].as_array().unwrap().iter().any(|w| w == super::pdf::BLEED_LEFT_OUT), "{v}");
|
||||
// A side the settings leave out warns, and sides they all keep don't.
|
||||
let (_, warnings) = pdf(&mut s, json!({"standard": "pdfX4", "bleed": {"top": 6, "bottom": 6, "left": 6}}));
|
||||
assert!(left_out(&warnings), "{warnings:?}");
|
||||
for p in [
|
||||
json!({"preset": "PDF/X-1a:2001"}),
|
||||
json!({"preset": "PDF/X-3:2002"}),
|
||||
json!({"preset": "PDF/X-4:2010"}),
|
||||
json!({"standard": "pdfX4", "bleed": {"useDocument": true}}),
|
||||
json!({"standard": "pdfX4", "bleed": {"top": 1, "bottom": 1, "left": 1, "right": 1}}),
|
||||
json!({}),
|
||||
json!({"standard": "pdfA2b"}),
|
||||
] {
|
||||
let (_, warnings) = pdf(&mut s, p.clone());
|
||||
assert!(!left_out(&warnings), "{p}: {warnings:?}");
|
||||
}
|
||||
// The dialog's Summary exports in memory, so it lists the warning too.
|
||||
let v = s.execute("document.pdfSettings", &json!({"standard": "pdfX4", "includeDocument": true})).unwrap();
|
||||
assert!(v["warnings"].as_array().unwrap().iter().any(|w| w == super::pdf::BLEED_LEFT_OUT), "{v}");
|
||||
// A bleed on the top alone: only that side counts.
|
||||
s.execute("document.setup", &json!({"bleed": [6, 0, 0, 0]})).unwrap();
|
||||
let (_, warnings) = pdf(&mut s, json!({"standard": "pdfX4", "bleed": {"top": 1}}));
|
||||
assert!(!left_out(&warnings), "{warnings:?}");
|
||||
let (_, warnings) = pdf(&mut s, json!({"standard": "pdfX4", "bleed": {"bottom": 1}}));
|
||||
assert!(left_out(&warnings), "{warnings:?}");
|
||||
// A document without a bleed has nothing to leave out.
|
||||
s.execute("document.setup", &json!({"bleed": 0})).unwrap();
|
||||
let (_, warnings) = pdf(&mut s, json!({"standard": "pdfX4"}));
|
||||
assert!(!left_out(&warnings), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// The ink amounts (0..1) `doc` shows at `p` in its images (the topmost image there).
|
||||
fn image_inks_at(doc: &vectorcraft_doc::Document, p: vectorcraft_geom::Point) -> Option<[f32; 4]> {
|
||||
fn visit(n: &vectorcraft_doc::Node, out: &mut Vec<vectorcraft_doc::ImageObject>) {
|
||||
|
||||
@@ -526,6 +526,20 @@ fn include_bleed_grows_each_artboard() {
|
||||
assert_eq!(size(&files(&r)[0].1), (40, 30), "without it, the artboard alone");
|
||||
}
|
||||
|
||||
/// Include Bleed puts the bleed on a PDF/X row's page, so the row doesn't warn that it leaves the bleed out.
|
||||
#[test]
|
||||
fn include_bleed_pdfx_rows_do_not_warn_about_the_bleed() {
|
||||
let mut s = session(1);
|
||||
s.execute("document.setup", &json!({"bleed": 6})).unwrap();
|
||||
let warned = |r: &Value| r["warnings"].as_array().unwrap().iter().any(|w| w == super::pdf::BLEED_LEFT_OUT);
|
||||
let row = json!([{"format": "pdf", "standard": "pdfX4"}]);
|
||||
assert!(warned(&export(&mut s, json!({"formats": row}))));
|
||||
assert!(!warned(&export(&mut s, json!({"includeBleed": true, "formats": row}))));
|
||||
// A selection is written on a page of its own bounds, which Include Bleed doesn't grow.
|
||||
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap();
|
||||
assert!(warned(&export(&mut s, json!({"includeBleed": true, "selection": "one", "formats": row}))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_8_rows_write_indexed_colour() {
|
||||
let mut s = session(1);
|
||||
|
||||
@@ -49,6 +49,8 @@ mod presets;
|
||||
mod report;
|
||||
|
||||
pub(crate) use analysis::{outlined_text, see_through_images};
|
||||
#[cfg(test)]
|
||||
pub(crate) use apply::with_every_region;
|
||||
pub(crate) use apply::{expand_transparent_tile_images, flatten_document, flatten_expanded, has_transparent_tile_images};
|
||||
pub use commands::specs;
|
||||
pub use options::FlattenOptions;
|
||||
|
||||
@@ -150,9 +150,15 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg
|
||||
}
|
||||
// An unclipped full raster needs no regions.
|
||||
let whole = o.balance <= 0.0 && !o.clip_complex_regions;
|
||||
let regions = if whole { vec![] } else { po::regions(&b.shapes) };
|
||||
// Nor does one that splits into more regions than the balance allows: the build stops at the
|
||||
// first region past the limit.
|
||||
let (regions, over) = match o.region_limit().filter(|_| !every_region()) {
|
||||
_ if whole => (vec![], false),
|
||||
Some(limit) => po::regions_within(&b.shapes, limit).map_or((vec![], true), |r| (r, false)),
|
||||
None => (po::regions(&b.shapes), false),
|
||||
};
|
||||
// An arrangement that failed (degenerate geometry) leaves only rasterizing.
|
||||
let all = regions.is_empty() || o.rasterize_all(regions.len());
|
||||
let all = over || regions.is_empty() || o.rasterize_all(regions.len());
|
||||
let mut vector: Vec<(usize, RegionFill)> = vec![];
|
||||
let mut complex: Vec<usize> = vec![];
|
||||
let mut gradients_only = !all;
|
||||
@@ -195,6 +201,32 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg
|
||||
Some((vector, area))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Groups flattened on this thread build all of their regions ([`with_every_region`]).
|
||||
static EVERY_REGION: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Whether groups build all of their regions, past the balance's limit too.
|
||||
#[cfg(test)]
|
||||
fn every_region() -> bool {
|
||||
EVERY_REGION.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn every_region() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// `f` with every group it flattens building all of its regions, past the balance's limit too.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_every_region<T>(f: impl FnOnce() -> T) -> T {
|
||||
EVERY_REGION.with(|e| e.set(true));
|
||||
let out = f();
|
||||
EVERY_REGION.with(|e| e.set(false));
|
||||
out
|
||||
}
|
||||
|
||||
/// The flat colour of a region covered by `sources` (CMYK in a `cmyk` document); `None` where
|
||||
/// nothing paints it.
|
||||
fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool) -> Option<RegionFill> {
|
||||
|
||||
@@ -169,9 +169,16 @@ impl FlattenOptions {
|
||||
}
|
||||
|
||||
/// Whether a group that splits into `regions` atomic regions is rasterized whole.
|
||||
pub(super) fn rasterize_all(&self, regions: usize) -> bool {
|
||||
pub(crate) fn rasterize_all(&self, regions: usize) -> bool {
|
||||
self.balance <= 0.0 || (self.balance < 100.0 && regions as f64 > 10f64.powf(self.balance / 20.0))
|
||||
}
|
||||
|
||||
/// The most atomic regions a group can split into without being rasterized whole, when a group
|
||||
/// with more needs none of its regions: `None` at a balance of 0 or 100, and with Clip Complex
|
||||
/// Regions (a group rasterized whole is clipped to all of its regions).
|
||||
pub(crate) fn region_limit(&self) -> Option<usize> {
|
||||
(!self.clip_complex_regions && self.balance > 0.0 && self.balance < 100.0).then(|| 10f64.powf(self.balance / 20.0).floor() as usize)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -42,7 +42,7 @@ use data::{fmt_value, parse_csv, to_csv};
|
||||
use generate::generate;
|
||||
#[cfg(test)]
|
||||
use series::default_series_color;
|
||||
pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, paint_targets};
|
||||
pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, shown_paint};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::Value;
|
||||
use super::{GraphSpec, Value};
|
||||
|
||||
pub(super) const LABEL_SIZE: f64 = 9.0;
|
||||
|
||||
@@ -32,6 +32,11 @@ pub(super) fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) {
|
||||
((lo / step).floor() * step, (hi / step).ceil() * step, step)
|
||||
}
|
||||
|
||||
/// The value axis of a radar graph: from 0 to the largest value of any series.
|
||||
pub(super) fn radar_axis(g: &GraphSpec) -> (f64, f64, f64) {
|
||||
value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max))
|
||||
}
|
||||
|
||||
/// A value axis: with both `min` and `max` set (Graph Type › Tick Values › Override Calculated Values), exactly that
|
||||
/// range split into `ticks` divisions (5 when automatic); otherwise a nice axis around the data, using whichever bound
|
||||
/// was given.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, tick_values, value_axis};
|
||||
use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, radar_axis, tick_values, value_axis};
|
||||
use super::columns::{Allowance, ColumnArt, column_art, column_designs};
|
||||
use super::data::fmt_value;
|
||||
use super::labels::{
|
||||
FILLED_LINE_BUDGET, Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, label_size, label_width, polyline, recolour_text, runs, shadow_of,
|
||||
Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_size, label_width, polyline, recolour_text, runs, shadow_of,
|
||||
};
|
||||
use super::markers::{MARKER_BUDGET, marker_cost, marker_designs};
|
||||
use super::series::{series_marks, with_series_opacity};
|
||||
@@ -264,7 +264,7 @@ impl<'a> Plot<'a> {
|
||||
right_edge: g.rect.x1,
|
||||
wedge_labels: vec![],
|
||||
on_top: vec![],
|
||||
filled: g.filled_lines && g.rows.len().saturating_mul(nser) <= FILLED_LINE_BUDGET,
|
||||
filled: filled_lines_on(g, nser),
|
||||
bars_reach: None,
|
||||
shadowed: vec![],
|
||||
series: vec![vec![]; nser],
|
||||
@@ -422,7 +422,7 @@ impl<'a> Plot<'a> {
|
||||
let (g, r, ncat, cs) = (self.g, self.r, self.ncat, self.cs);
|
||||
let centre = r.center();
|
||||
let rad = (r.width().min(r.height()) / 2.0).max(1.0);
|
||||
let (lo, hi, step) = value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max));
|
||||
let (lo, hi, step) = radar_axis(g);
|
||||
let at = |c: usize, v: f64| {
|
||||
let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * c as f64 / ncat as f64;
|
||||
centre + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) * (rad * ((v - lo) / (hi - lo)).clamp(0.0, 1.0))
|
||||
|
||||
@@ -90,9 +90,10 @@ impl Gen<'_> {
|
||||
};
|
||||
// A run the outline can't be made for (its points all in one place) stays a thin line.
|
||||
if !band.is_empty() {
|
||||
// The band takes the series' colour, set as a fill or, on a thin line, as its stroke; a thin black outline.
|
||||
// The band takes the series' color, set as a fill or, on a thin line, as a stroke with paint; a thin black outline.
|
||||
let slot = g.series_paints.get(s);
|
||||
let fill = slot.and_then(|p| p.fill.clone().or_else(|| p.stroke.clone())).unwrap_or_else(|| default_series_paint(s));
|
||||
let fill =
|
||||
slot.and_then(|p| p.fill.clone().or_else(|| p.stroke.clone().filter(|st| !st.is_none()))).unwrap_or_else(|| default_series_paint(s));
|
||||
return self.path(band, fill, Paint::solid(Color::BLACK), 0.25);
|
||||
}
|
||||
let (fill, stroke, w) = series_marks(g, s, false, default_series_paint(s), 1.0);
|
||||
@@ -140,7 +141,12 @@ pub(super) const SHADOW_OPACITY: f32 = 0.35;
|
||||
/// Draw Filled Lines: how wide a filled line is.
|
||||
const FILLED_LINE_WIDTH: f64 = 3.0;
|
||||
|
||||
pub(super) const FILLED_LINE_BUDGET: usize = 20_000;
|
||||
const FILLED_LINE_BUDGET: usize = 20_000;
|
||||
|
||||
/// Does graph `g`, with `series` series, draw Filled Lines (up to FILLED_LINE_BUDGET data points)?
|
||||
pub(super) fn filled_lines_on(g: &GraphSpec, series: usize) -> bool {
|
||||
g.filled_lines && g.rows.len().saturating_mul(series) <= FILLED_LINE_BUDGET
|
||||
}
|
||||
|
||||
/// Most marks a graph draws shadows for (each is a copy).
|
||||
pub(super) const SHADOW_BUDGET: usize = 200_000;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::labels::{Role, label_style};
|
||||
use super::axis::radar_axis;
|
||||
use super::labels::{Role, filled_lines_on, label_style};
|
||||
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
|
||||
|
||||
// ---------- generation ----------
|
||||
@@ -121,11 +122,82 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo
|
||||
let node = doc.node(*id)?;
|
||||
let paint = node.appearance.paint_at(None, fill)?.clone();
|
||||
let width = (!fill).then(|| node.appearance.stroke_width());
|
||||
Some((r.graph, r.index, paint, width))
|
||||
Some((r.graph, r.index, fill, paint, width))
|
||||
})
|
||||
.collect();
|
||||
store_series_paints(doc, captures)
|
||||
}
|
||||
|
||||
/// The paint of the topmost visible fill (`true`) or stroke (`false`) of `node`, and a stroke's weight (0 without paint).
|
||||
/// With no such row: no paint.
|
||||
pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) {
|
||||
match node.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()) {
|
||||
Some(vectorcraft_doc::AppearanceItem::Stroke(st)) if !st.paint.is_none() => (st.paint.clone(), st.width),
|
||||
Some(it) => (it.paint().clone(), 0.0),
|
||||
None => (Paint::None, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the paint of the topmost visible fill (`true`) or stroke (`false`), and a stroke's weight, of generated series
|
||||
/// art just edited (`edited`: each object with the kind of row, later entries replacing earlier ones) into that series'
|
||||
/// fill or stroke; art left without such a row keeps no paint. A series that draws Filled Lines bands stores no stroke: its
|
||||
/// bands keep their own outline. Called inside the same document edit, so both undo together. Returns whether a series'
|
||||
/// captured fill or stroke changed.
|
||||
pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool {
|
||||
if edited.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let members = series_members(doc);
|
||||
if members.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Each series is checked once: the check reads the graph's whole grid.
|
||||
let mut bands: HashMap<(NodeId, usize), bool> = HashMap::new();
|
||||
let mut captures = Vec::new();
|
||||
for (id, fill) in edited {
|
||||
// Only the paths the graph draws again, not the copy of a graph design.
|
||||
let Some(r) = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group)) else { continue };
|
||||
let Some(node) = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. })) else { continue };
|
||||
let drawn = || doc.node(r.graph).and_then(|n| n.extras().graph.as_deref()).is_some_and(|g| series_draws_bands(g, r.index));
|
||||
if !*fill && *bands.entry((r.graph, r.index)).or_insert_with(drawn) {
|
||||
continue;
|
||||
}
|
||||
let (paint, width) = shown_paint(node, *fill);
|
||||
captures.push((r.graph, r.index, *fill, paint, (!*fill).then_some(width)));
|
||||
}
|
||||
store_series_paints(doc, captures)
|
||||
}
|
||||
|
||||
/// Does series `i` draw any Filled Lines band? A line needs two neighboring values, a scatter series two neighboring points
|
||||
/// in different places (its index counts pairs of columns), and a radar series two categories with a value plotted away
|
||||
/// from the center.
|
||||
fn series_draws_bands(g: &GraphSpec, i: usize) -> bool {
|
||||
let series = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES);
|
||||
if !g.connect_points || !filled_lines_on(g, series) {
|
||||
return false;
|
||||
}
|
||||
let cells = g.cells();
|
||||
let cell = |row: &[Option<f64>], s: usize| row.get(s).copied().flatten();
|
||||
match g.series_kind(i) {
|
||||
GraphKind::Line => cells.windows(2).any(|w| w.iter().all(|row| cell(row, i).is_some())),
|
||||
GraphKind::Radar => {
|
||||
let (lo, hi, _) = radar_axis(g);
|
||||
cells.len() > 1 && cells.iter().any(|row| (cell(row, i).unwrap_or(0.0) - lo) / (hi - lo) > 0.0)
|
||||
}
|
||||
GraphKind::Scatter => {
|
||||
let s = i.saturating_mul(2);
|
||||
let point = |row: &[Option<f64>]| Some((cell(row, s)?, cell(row, s.saturating_add(1))?));
|
||||
cells.windows(2).any(|w| matches!(w, [a, b] if point(a).is_some() && point(b).is_some() && point(a) != point(b)))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a
|
||||
/// series' captured fill or stroke changed.
|
||||
fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option<f64>)>) -> bool {
|
||||
let mut changed = false;
|
||||
for (graph, index, paint, width) in captures {
|
||||
for (graph, index, fill, paint, width) 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 };
|
||||
let was = spec.series_paints.clone();
|
||||
// `index` is below the graph's series count (`series_members`).
|
||||
@@ -144,7 +216,7 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo
|
||||
changed
|
||||
}
|
||||
|
||||
/// Copy the opacity of the topmost fill (`true`) or stroke (`false`) of generated series art just edited (`edited`:
|
||||
/// Copy the opacity of the topmost visible fill (`true`) or stroke (`false`) of generated series art just edited (`edited`:
|
||||
/// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind
|
||||
/// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity
|
||||
/// changed.
|
||||
@@ -162,7 +234,8 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool
|
||||
// Only the paths the graph draws again, not the copy of a graph design.
|
||||
let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?;
|
||||
let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?;
|
||||
let opacity = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill).map_or(1.0, vectorcraft_doc::AppearanceItem::opacity);
|
||||
let row = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill && it.visible());
|
||||
let opacity = row.map_or(1.0, vectorcraft_doc::AppearanceItem::opacity);
|
||||
Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity)))
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -503,6 +503,819 @@ fn a_stroke_opacity_on_a_series_line_survives_regeneration() {
|
||||
assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6));
|
||||
}
|
||||
|
||||
/// The colors of series `index`'s marks: each mark's top fill and stroke paint and its stroke weight.
|
||||
fn marks_of(s: &Session, id: NodeId, index: u32) -> Vec<(Paint, Paint, f64)> {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let marks = series(&n, index).children().unwrap().to_vec();
|
||||
marks.iter().map(|m| (m.appearance.fill_paint(), m.appearance.stroke_paint(), m.appearance.stroke_width())).collect()
|
||||
}
|
||||
|
||||
/// Set the top fill of series 0's first mark to the color `hex` with `appearance.setItem`, and return that paint.
|
||||
fn hex_paint(s: &mut Session, id: NodeId, hex: &str) -> Paint {
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
let row = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": hex})).unwrap();
|
||||
s.doc().unwrap().doc.node(bar).unwrap().appearance.fill_paint()
|
||||
}
|
||||
|
||||
/// A color or weight set with `appearance.setItem` on the topmost fill or stroke of one series mark is kept by the
|
||||
/// graph: every mark of that series has it after the graph is drawn again, and undo takes it back with its edit. A row
|
||||
/// under the topmost one is not kept, and a change to one kind of row keeps nothing for the other.
|
||||
#[test]
|
||||
fn an_appearance_item_paint_on_a_series_mark_survives_regeneration() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let blue = hex_paint(&mut s, id, "#0000ff");
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(blue.clone()), None, None));
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
let row = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.iter().rposition(|it| !it.is_fill()).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": "#ff0000", "weight": 3})).unwrap();
|
||||
let red = s.doc().unwrap().doc.node(bar).unwrap().appearance.stroke_paint();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.stroke, kept.stroke_width), (Some(red.clone()), Some(3.0)));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| *m == (blue.clone(), red.clone(), 3.0)), "{:?}", marks_of(&s, id, 0));
|
||||
assert!(marks_of(&s, id, 1).iter().all(|m| m.0 != blue));
|
||||
// Undo goes back through the data edit, then takes the stroke paint away with its edit.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(blue.clone()), None, None));
|
||||
// The graph does not keep a color set on a fill under the topmost one.
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
|
||||
let (lower, _) = fill_rows(&s, bar);
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": lower, "color": "#00ff00"})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue));
|
||||
}
|
||||
|
||||
/// A weight set on a stroke row, with no color, is kept by the graph with the row's paint.
|
||||
#[test]
|
||||
fn an_appearance_item_weight_on_a_series_mark_survives_regeneration() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
|
||||
let row = bar.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.id.0], "index": row, "color": "#ff0000"})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.id.0], "index": row, "weight": 4})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(4.0));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| m.2 == 4.0), "{:?}", marks_of(&s, id, 0));
|
||||
}
|
||||
|
||||
/// A column graph with series 0 kept blue at 40% opacity: the graph, the paint, the first marks of series 0 and 1.
|
||||
fn blue_source() -> (Session, NodeId, Paint, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let blue = hex_paint(&mut s, id, "#0000ff");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let (from, to) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id);
|
||||
s.execute("transparency.set", &json!({"ids": [from.0], "item": 0, "opacity": 40})).unwrap();
|
||||
(s, id, blue, from, to)
|
||||
}
|
||||
|
||||
/// After the graph is drawn again, every mark of series 1 is `blue` at 40%; undo goes back through the data edit and the
|
||||
/// command before it, which restores the paints the graph kept in `was`.
|
||||
fn series_1_is_blue_at_40_then_undo(s: &mut Session, id: NodeId, blue: &Paint, was: &[vectorcraft_doc::SeriesPaint]) {
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
for m in series(&n, 1).children().unwrap() {
|
||||
let f = m.appearance.fill().unwrap();
|
||||
assert_eq!((f.paint.clone(), f.opacity), (blue.clone(), 0.4));
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(s, id).series_paints, was);
|
||||
}
|
||||
|
||||
/// The topmost fill (`true`) or stroke (`false`) row of `mark`.
|
||||
fn top_row(s: &Session, mark: NodeId, fill: bool) -> usize {
|
||||
s.doc().unwrap().doc.node(mark).unwrap().appearance.items.iter().rposition(|it| it.is_fill() == fill).unwrap()
|
||||
}
|
||||
|
||||
/// A new column graph, the fill of series 0's marks and those marks' ids.
|
||||
fn column_marks() -> (Session, NodeId, Paint, Vec<NodeId>) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let marks = series(&n, 0).children().unwrap().to_vec();
|
||||
(s, id, marks[0].appearance.fill_paint(), marks.iter().map(|m| m.id).collect())
|
||||
}
|
||||
|
||||
/// An appearance moved onto a mark of another series makes the graph keep its paint with its opacity for that series,
|
||||
/// and leaves the source series without paint.
|
||||
#[test]
|
||||
fn moving_an_appearance_onto_a_series_mark_keeps_its_paint() {
|
||||
let (mut s, id, blue, from, to) = blue_source();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0})).unwrap();
|
||||
let paints = spec(&s, id).series_paints;
|
||||
assert_eq!((paints[0].fill.clone(), paints[1].fill.clone()), (Some(Paint::None), Some(blue.clone())));
|
||||
series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was);
|
||||
}
|
||||
|
||||
/// An appearance copied onto a mark of another series makes the graph keep its paint with its opacity for that series.
|
||||
#[test]
|
||||
fn copying_an_appearance_onto_a_series_mark_keeps_its_paint() {
|
||||
let (mut s, id, blue, from, to) = blue_source();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0, "copy": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone()));
|
||||
series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was);
|
||||
}
|
||||
|
||||
/// An Eyedropper pick onto a mark of another series makes the graph keep its paint with its opacity for that series.
|
||||
#[test]
|
||||
fn an_eyedropper_pick_onto_a_series_mark_keeps_its_paint() {
|
||||
let (mut s, id, blue, from, to) = blue_source();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": [to.0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone()));
|
||||
series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was);
|
||||
}
|
||||
|
||||
/// A reversed Eyedropper (Alt-click) copies onto the clicked mark, and the graph keeps the paint for the clicked series.
|
||||
#[test]
|
||||
fn a_reversed_eyedropper_pick_keeps_its_paint_on_the_clicked_series() {
|
||||
let (mut s, id, blue, from, to) = blue_source();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.copyFrom", &json!({"source": to.0, "ids": [from.0], "reverse": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone()));
|
||||
series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was);
|
||||
}
|
||||
|
||||
/// A reversed Eyedropper with nothing selected copies what it picked up last onto the clicked mark.
|
||||
#[test]
|
||||
fn a_reversed_eyedropper_pick_of_the_last_sample_keeps_its_paint() {
|
||||
let (mut s, id, blue, from, to) = blue_source();
|
||||
s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": []})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": to.0, "reverse": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue));
|
||||
}
|
||||
|
||||
/// Clear Appearance on a series mark leaves it with no fill and no stroke, and the series stays that way when the graph
|
||||
/// is drawn again. Undo brings the kept paint back.
|
||||
#[test]
|
||||
fn clearing_a_series_marks_appearance_updates_its_kept_paint() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let row = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": row, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.clear", &json!({"ids": [marks[0].0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(Paint::None), Some(Paint::None), Some(0.0)));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| *m == (Paint::None, Paint::None, 0.0)), "{:?}", marks_of(&s, id, 0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
}
|
||||
|
||||
/// A mark with `blue` under a second fill: the graph, the paint, the mark and its (lower, upper) fill rows, with the
|
||||
/// upper row green and kept.
|
||||
fn two_fills() -> (Session, NodeId, Paint, NodeId, usize, usize) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let blue = hex_paint(&mut s, id, "#0000ff");
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
|
||||
let (lower, upper) = fill_rows(&s, bar);
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "color": "#00ff00"})).unwrap();
|
||||
assert_ne!(spec(&s, id).series_paints[0].fill, Some(blue.clone()));
|
||||
(s, id, blue, bar, lower, upper)
|
||||
}
|
||||
|
||||
/// `cmd` makes the graph keep `blue`, and undo brings back the paints it kept before.
|
||||
fn top_row_command_keeps_blue(s: &mut Session, id: NodeId, blue: &Paint, cmd: &str, p: &serde_json::Value) {
|
||||
let was = spec(s, id).series_paints;
|
||||
s.execute(cmd, p).unwrap();
|
||||
assert_eq!(spec(s, id).series_paints[0].fill.as_ref(), Some(blue), "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(s, id).series_paints, was, "{cmd}");
|
||||
}
|
||||
|
||||
/// Reduce to Basic Appearance with the lower fill as the active row leaves that fill, and the graph keeps its paint.
|
||||
#[test]
|
||||
fn reducing_a_series_marks_appearance_keeps_the_row_it_leaves() {
|
||||
let (mut s, id, blue, bar, lower, _) = two_fills();
|
||||
s.execute("select.set", &json!({"ids": [bar.0]})).unwrap();
|
||||
s.execute("appearance.setActiveItem", &json!({"index": lower})).unwrap();
|
||||
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.reduceToBasic", &json!({}));
|
||||
}
|
||||
|
||||
/// Removing a mark's top row makes the graph keep the paint now on top.
|
||||
#[test]
|
||||
fn removing_a_series_marks_top_row_keeps_the_paint_now_on_top() {
|
||||
let (mut s, id, blue, bar, _, upper) = two_fills();
|
||||
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.removeItem", &json!({"ids": [bar.0], "index": upper}));
|
||||
}
|
||||
|
||||
/// Moving a lower row above the others makes the graph keep its paint.
|
||||
#[test]
|
||||
fn moving_a_row_to_a_series_marks_top_keeps_its_paint() {
|
||||
let (mut s, id, blue, bar, lower, upper) = two_fills();
|
||||
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.moveItem", &json!({"ids": [bar.0], "from": lower, "to": upper}));
|
||||
}
|
||||
|
||||
/// Moving a block of rows above the others makes the graph keep the paint now on top.
|
||||
#[test]
|
||||
fn moving_a_block_of_rows_to_a_series_marks_top_keeps_its_paint() {
|
||||
let (mut s, id, blue, bar, lower, upper) = two_fills();
|
||||
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.moveItem", &json!({"ids": [bar.0], "indices": [lower], "to": upper}));
|
||||
}
|
||||
|
||||
/// A copy of a lower row placed on top makes the graph keep its paint.
|
||||
#[test]
|
||||
fn a_copy_placed_on_a_series_marks_top_keeps_its_paint() {
|
||||
let (mut s, id, blue, bar, lower, upper) = two_fills();
|
||||
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.duplicateItem", &json!({"ids": [bar.0], "index": lower, "to": upper + 1}));
|
||||
}
|
||||
|
||||
/// The mark's only stroke row removed: the series draws without a stroke; a new row comes back as its paint.
|
||||
#[test]
|
||||
fn removing_and_adding_a_series_marks_only_stroke_updates_its_kept_paint() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let bar = marks[0];
|
||||
let row = top_row(&s, bar, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.removeItem", &json!({"ids": [bar.0], "index": row})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (None, Some(Paint::None), Some(0.0)));
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.addStroke", &json!({"ids": [bar.0]})).unwrap();
|
||||
let stroke = s.doc().unwrap().doc.node(bar).unwrap().appearance.stroke_paint();
|
||||
assert!(!stroke.is_none());
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(stroke));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
}
|
||||
|
||||
/// Hiding the top row makes the graph keep the visible row under it, and its opacity; hiding the only one keeps no paint;
|
||||
/// Show All Hidden Attributes brings the top row back. A hidden row on top of a moved appearance is not kept.
|
||||
#[test]
|
||||
fn a_hidden_top_row_is_not_what_the_graph_keeps() {
|
||||
let (mut s, id, blue, bar, lower, upper) = two_fills();
|
||||
let green = s.doc().unwrap().doc.node(bar).unwrap().appearance.fill_paint();
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": lower, "opacity": 50})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None, "the row under the top one");
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5)));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == blue), "{:?}", marks_of(&s, id, 0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("appearance.showAllHidden", &json!({"ids": [bar.0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.fill_opacity), (Some(green), None));
|
||||
|
||||
// A transfer of [blue, hidden green] onto another series keeps the blue the source shows.
|
||||
let (mut s, id, blue, bar, _, upper) = two_fills();
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap();
|
||||
let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
s.execute("appearance.transfer", &json!({"source": bar.0, "target": to.0, "copy": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue));
|
||||
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "visible": false})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None));
|
||||
}
|
||||
|
||||
/// Hiding a top row and Show All Hidden Attributes each undo in one step, with what the graph stores.
|
||||
#[test]
|
||||
fn hiding_and_showing_a_series_marks_top_row_undo_with_the_stored_paint() {
|
||||
let (mut s, id, _, bar, _, upper) = two_fills();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap();
|
||||
let hidden = spec(&s, id).series_paints;
|
||||
assert_ne!(hidden, was);
|
||||
s.execute("appearance.showAllHidden", &json!({"ids": [bar.0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, hidden);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
}
|
||||
|
||||
/// An opaque Eyedropper pick onto a mark replaces the opacity another mark of the series stored, even though the mark
|
||||
/// itself stays opaque: the series is drawn with the picked color at 100%.
|
||||
#[test]
|
||||
fn an_opaque_pick_replaces_the_opacity_the_series_stores() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4));
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "color": "#0000ff"})).unwrap();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!(series(&n, 0).children().unwrap().iter().all(|m| m.appearance.fill().unwrap().opacity == 1.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
|
||||
// A pick of the opacity alone replaces it too, and a pick that leaves opacity out keeps it.
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap();
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
let colors_only = json!({"appearance": {"fill": {"color": true, "transparency": false}, "stroke": {"color": false}}});
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": colors_only})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4));
|
||||
let opacity_only = json!({"appearance": {"fill": {"color": false, "transparency": true}, "stroke": {"color": false}}});
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": opacity_only})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
|
||||
}
|
||||
|
||||
/// An appearance moved between two marks of one series leaves the series with the target's paint, not the source's
|
||||
/// empty one, and the series still shows when the graph is drawn again.
|
||||
#[test]
|
||||
fn moving_an_appearance_within_a_series_keeps_the_targets_paint() {
|
||||
let (mut s, id, shown, marks) = column_marks();
|
||||
s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": marks[1].0})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(shown.clone()));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == shown), "{:?}", marks_of(&s, id, 0));
|
||||
}
|
||||
|
||||
/// A row shown again with the color the mark already shows replaces the paint another mark of the series stored.
|
||||
#[test]
|
||||
fn showing_a_row_with_the_color_a_mark_shows_replaces_the_kept_paint() {
|
||||
let (mut s, id, shown, marks) = column_marks();
|
||||
s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap();
|
||||
let (_, upper) = fill_rows(&s, marks[0]);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap();
|
||||
let other = top_row(&s, marks[1], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": other, "color": "#ff0000"})).unwrap();
|
||||
assert_ne!(spec(&s, id).series_paints[0].fill, Some(shown.clone()));
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": true, "color": "#000000"})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint()));
|
||||
}
|
||||
|
||||
/// A color or weight set again to what a mark already shows and an Eyedropper pick that leaves the mark as it was replace
|
||||
/// the paint another mark of the series stored, in an undo step of their own. Clear Appearance on a clear mark leaves the
|
||||
/// stored paint and the undo history as they are.
|
||||
#[test]
|
||||
fn writing_the_paint_a_series_mark_already_shows_replaces_the_kept_paint() {
|
||||
let (mut s, id, shown, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
|
||||
assert_ne!(spec(&s, id).series_paints[0].fill, Some(shown.clone()));
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "color": "#000000"})).unwrap();
|
||||
let again = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(again));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
for (mark, weight) in [(marks[1], 2), (marks[0], 5), (marks[1], 2)] {
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": stroke, "color": "#ff0000", "weight": weight})).unwrap();
|
||||
}
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0));
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "weight": 5})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "weight": 2})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0));
|
||||
|
||||
let (mut s, id, shown, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": marks[2].0, "ids": [marks[1].0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(shown));
|
||||
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
|
||||
let was = spec(&s, id).series_paints;
|
||||
let shown = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint();
|
||||
s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_ne!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(), shown, "the undo took back the color, not a clear");
|
||||
}
|
||||
|
||||
/// A line graph with a marker: the graph, the first thin line, a marker.
|
||||
fn line_and_marker() -> (Session, NodeId, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "line");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let marks = series(&n, 0).children().unwrap().to_vec();
|
||||
let line = marks.iter().find(|m| m.appearance.stroke_width() > 0.0).unwrap().id;
|
||||
let marker = marks.iter().find(|m| m.appearance.stroke_width() == 0.0 && m.appearance.stroke().is_some()).unwrap().id;
|
||||
(s, id, line, marker)
|
||||
}
|
||||
|
||||
/// The first thin line of series 0, and every other path of the series (its markers and legend swatch).
|
||||
fn line_parts(s: &Session, id: NodeId) -> (vectorcraft_doc::Node, Vec<vectorcraft_doc::Node>) {
|
||||
let parts = series_parts(s, id, 0);
|
||||
let (lines, others): (Vec<_>, Vec<_>) =
|
||||
parts.into_iter().partition(|m| m.appearance.fill_paint().is_none() && m.appearance.stroke_width() == 1.0);
|
||||
(lines[0].clone(), others)
|
||||
}
|
||||
|
||||
/// On a line graph, a stroke set on the thin line goes on the line after a redraw; a fill set on the thin line goes on
|
||||
/// the markers and the legend swatch, and the line stays without a fill.
|
||||
#[test]
|
||||
fn a_line_series_draws_its_kept_stroke_on_the_line_and_its_kept_fill_on_the_markers() {
|
||||
let (mut s, id, line, _) = line_and_marker();
|
||||
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap();
|
||||
let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint();
|
||||
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, true), "color": "#0000ff"})).unwrap();
|
||||
let blue = s.doc().unwrap().doc.node(line).unwrap().appearance.fill_paint();
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let (drawn, others) = line_parts(&s, id);
|
||||
assert_eq!((drawn.appearance.fill_paint(), drawn.appearance.stroke_paint()), (Paint::None, red));
|
||||
assert!(others.iter().all(|m| m.appearance.fill_paint() == blue), "{others:?}");
|
||||
}
|
||||
|
||||
/// Clear Appearance on a thin line leaves the fill the graph stores for the markers; on a marker that shows no stroke, it
|
||||
/// leaves the stroke the graph stores for the line. An Eyedropper pick of a stroke-only source onto the line and a copy
|
||||
/// of one line onto another leave the stored fill too.
|
||||
#[test]
|
||||
fn a_row_left_without_paint_leaves_the_paint_the_series_stores() {
|
||||
let (mut s, id, line, marker) = line_and_marker();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, true), "color": "#0000ff"})).unwrap();
|
||||
let blue = spec(&s, id).series_paints[0].fill.clone().unwrap();
|
||||
s.execute("appearance.clear", &json!({"ids": [line.0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill.as_ref(), kept.stroke.as_ref()), (Some(&blue), Some(&Paint::None)));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let parts = series_parts(&s, id, 0);
|
||||
assert!(parts.iter().filter(|m| !m.appearance.fill_paint().is_none()).all(|m| m.appearance.fill_paint() == blue));
|
||||
assert!(parts.iter().any(|m| m.appearance.fill_paint() == blue), "the markers keep their fill");
|
||||
|
||||
let (mut s, id, line, marker) = line_and_marker();
|
||||
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap();
|
||||
let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint();
|
||||
s.execute("appearance.clear", &json!({"ids": [marker.0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()), "the marker showed no stroke");
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert_eq!(line_parts(&s, id).0.appearance.stroke_paint(), red);
|
||||
|
||||
let (mut s, id, line, marker) = line_and_marker();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, true), "color": "#0000ff"})).unwrap();
|
||||
let blue = spec(&s, id).series_paints[0].fill.clone().unwrap();
|
||||
let other = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
s.execute("appearance.copyFrom", &json!({"source": other.0, "ids": [line.0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue.clone()), "the source's line has no fill");
|
||||
s.execute("appearance.transfer", &json!({"source": other.0, "target": line.0, "copy": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue));
|
||||
}
|
||||
|
||||
/// A line graph with Filled Lines on: the session, the graph and series 0's band.
|
||||
fn band_graph() -> (Session, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = make(&mut s, "line", ",a\nQ1,2\nQ2,3\nQ3,2");
|
||||
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap();
|
||||
let band = series_parts(&s, id, 0)[0].id;
|
||||
(s, id, band)
|
||||
}
|
||||
|
||||
/// The band's fill, outline paint and outline weight after the graph is drawn again.
|
||||
fn redrawn_band(s: &mut Session, id: NodeId) -> (Paint, Paint, f64) {
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [4]]})).unwrap();
|
||||
let band = series_parts(s, id, 0)[0].clone();
|
||||
(band.appearance.fill_paint(), band.appearance.stroke_paint(), band.appearance.stroke_width())
|
||||
}
|
||||
|
||||
/// A fill set on a Filled Lines band is kept, and the band keeps its thin black outline.
|
||||
#[test]
|
||||
fn a_filled_line_band_keeps_a_fill_set_on_it() {
|
||||
let (mut s, id, band) = band_graph();
|
||||
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": top_row(&s, band, true), "color": "#0000ff"})).unwrap();
|
||||
let blue = s.doc().unwrap().doc.node(band).unwrap().appearance.fill_paint();
|
||||
assert_eq!(redrawn_band(&mut s, id), (blue, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
|
||||
}
|
||||
|
||||
/// A stroke set on a Filled Lines band is not kept: the band keeps its color and its thin black outline.
|
||||
#[test]
|
||||
fn a_stroke_set_on_a_filled_line_band_does_not_repaint_it() {
|
||||
let (mut s, id, band) = band_graph();
|
||||
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
|
||||
let row = top_row(&s, band, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "color": "#ff0000", "weight": 3})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none()));
|
||||
assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
|
||||
}
|
||||
|
||||
/// An opacity set on a Filled Lines band's stroke row is kept, without a stroke paint, and goes on the band's thin black
|
||||
/// outline when the graph is drawn again.
|
||||
#[test]
|
||||
fn a_stroke_opacity_set_on_a_filled_line_band_goes_on_its_outline() {
|
||||
let (mut s, id, band) = band_graph();
|
||||
let row = top_row(&s, band, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 30})).unwrap();
|
||||
let kept = spec(&s, id).series_paints.first().cloned().unwrap_or_default();
|
||||
assert_eq!((kept.stroke.is_none(), kept.stroke_opacity), (true, Some(0.3)));
|
||||
let (_, outline, width) = redrawn_band(&mut s, id);
|
||||
assert_eq!((outline, width), (Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
|
||||
let band = series_parts(&s, id, 0)[0].clone();
|
||||
assert_eq!(band.appearance.stroke().unwrap().opacity, 0.3);
|
||||
}
|
||||
|
||||
/// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the
|
||||
/// band its color: an empty stored stroke is not a color for the band.
|
||||
#[test]
|
||||
fn removing_a_filled_line_bands_stroke_leaves_its_color() {
|
||||
let (mut s, id, band) = band_graph();
|
||||
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
|
||||
s.execute("appearance.removeItem", &json!({"ids": [band.0], "index": top_row(&s, band, false)})).unwrap();
|
||||
assert_eq!(redrawn_band(&mut s, id).0, was);
|
||||
|
||||
let (mut s, id, band) = band_graph();
|
||||
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
|
||||
s.execute("paint.setStroke", &json!({"ids": [band.0], "none": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None));
|
||||
assert_eq!(redrawn_band(&mut s, id).0, was);
|
||||
}
|
||||
|
||||
/// With Connect Data Points off a Filled Lines graph draws no bands, so a stroke set on a marker is kept.
|
||||
#[test]
|
||||
fn a_stroke_on_a_marker_is_kept_when_filled_lines_draws_no_bands() {
|
||||
let (mut s, id, _) = band_graph();
|
||||
s.execute("graph.setType", &json!({"id": id.0, "connectPoints": false})).unwrap();
|
||||
let marker = series_parts(&s, id, 0)[0].id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, false), "color": "#ff0000"})).unwrap();
|
||||
assert!(spec(&s, id).series_paints[0].stroke.as_ref().is_some_and(|p| !p.is_none()));
|
||||
}
|
||||
|
||||
/// A Filled Lines graph of `ty` and `csv` draws no bands, so a stroke set on its first mark is stored and stays on it after
|
||||
/// the graph is drawn again.
|
||||
fn stroke_is_kept_without_bands(ty: &str, csv: &str) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = make(&mut s, ty, csv);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap();
|
||||
let mark = series_parts(&s, id, 0)[0].id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": "#ff0000", "weight": 2})).unwrap();
|
||||
let red = s.doc().unwrap().doc.node(mark).unwrap().appearance.stroke_paint();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()));
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
|
||||
let drawn = &series_parts(&s, id, 0)[0];
|
||||
assert_eq!((drawn.appearance.stroke_paint(), drawn.appearance.stroke_width()), (red, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_is_kept_on_a_one_row_line_series() {
|
||||
stroke_is_kept_without_bands("line", ",a\nQ1,2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_is_kept_on_a_radar_series_of_zeros() {
|
||||
stroke_is_kept_without_bands("radar", ",a\nQ1,0\nQ2,0\nQ3,0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_is_kept_on_a_scatter_series_of_one_repeated_point() {
|
||||
stroke_is_kept_without_bands("scatter", "a,b\n1,2\n1,2");
|
||||
}
|
||||
|
||||
/// A radar series whose values all fall below `axisMin` is plotted at the center and draws no band, so a stroke set on its
|
||||
/// marks is kept. A radar series with a value above `axisMin` draws a band and stores no stroke.
|
||||
#[test]
|
||||
fn a_radar_series_draws_a_band_only_for_values_away_from_the_center() {
|
||||
for (csv, kept) in [(",a\nQ1,1\nQ2,2\nQ3,3", true), (",a\nQ1,1\nQ2,15\nQ3,3", false)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = make(&mut s, "radar", csv);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true, "axisMin": 10, "axisMax": 20})).unwrap();
|
||||
let mark = series_parts(&s, id, 0)[0].id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": "#ff0000", "weight": 2})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints.first().is_some_and(|p| p.stroke.is_some()), kept, "{csv}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Past the Filled Lines budget the graph draws thin lines, and a stroke set on one is kept.
|
||||
#[test]
|
||||
fn a_stroke_on_a_thin_line_past_the_filled_lines_budget_is_kept() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let rows: Vec<Vec<f64>> = (0..8_000).map(|i| vec![(i % 7) as f64, 1.0, 2.0]).collect();
|
||||
let id = NodeId(
|
||||
s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true, "markPoints": false})).unwrap();
|
||||
let line = series_parts(&s, id, 0)[0].id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap();
|
||||
assert!(spec(&s, id).series_paints[0].stroke.as_ref().is_some_and(|p| !p.is_none()));
|
||||
}
|
||||
|
||||
/// A weight of 0 set again on a painted stroke replaces the weight another mark of the series left on the graph.
|
||||
#[test]
|
||||
fn a_zero_weight_on_a_painted_stroke_replaces_the_kept_weight() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 0})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0));
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "weight": 0})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(0.0));
|
||||
}
|
||||
|
||||
/// An Eyedropper `append` of a source with no stroke leaves the stroke the series keeps.
|
||||
#[test]
|
||||
fn appending_a_fill_only_appearance_leaves_the_kept_stroke() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap();
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
s.execute("appearance.removeItem", &json!({"ids": [source.0], "index": top_row(&s, source, false)})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0));
|
||||
// A pick of the weight alone from that source leaves the mark and what the series keeps as they were.
|
||||
let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": false, "weight": true}}});
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0));
|
||||
}
|
||||
|
||||
/// An Eyedropper `append` of a source with only a hidden fill leaves the fill the series keeps.
|
||||
#[test]
|
||||
fn appending_a_hidden_fill_leaves_the_kept_fill() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].fill.clone();
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "visible": false})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, kept);
|
||||
}
|
||||
|
||||
/// An Eyedropper pick of the weight alone reads a hidden stroke too, and that weight replaces the one the series keeps.
|
||||
#[test]
|
||||
fn picking_the_weight_of_a_hidden_stroke_replaces_the_kept_weight() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap();
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
let row = top_row(&s, source, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#00ff00", "weight": 2, "visible": false})).unwrap();
|
||||
let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": false, "weight": true}}});
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0));
|
||||
}
|
||||
|
||||
/// An Eyedropper pick of some attributes edits the mark's topmost stroke. When that row is hidden, the series keeps what
|
||||
/// it stores instead of taking the visible row under it.
|
||||
#[test]
|
||||
fn a_partial_pick_onto_a_hidden_top_row_leaves_the_kept_stroke() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.addStroke", &json!({"ids": [marks[1].0]})).unwrap();
|
||||
let hidden = top_row(&s, marks[1], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": hidden, "visible": false})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
let row = top_row(&s, source, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#00ff00", "weight": 3})).unwrap();
|
||||
let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": true, "weight": true}}});
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0], kept);
|
||||
}
|
||||
|
||||
/// An Eyedropper `append` writes a visible row under the source's hidden top row, and the series stores that row's paint
|
||||
/// even when the mark already shows the same paint.
|
||||
#[test]
|
||||
fn appending_a_visible_row_under_a_hidden_one_replaces_the_kept_stroke() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
let row = top_row(&s, source, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.addStroke", &json!({"ids": [source.0]})).unwrap();
|
||||
let hidden = top_row(&s, source, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": hidden, "visible": false})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap();
|
||||
let kept = &spec(&s, id).series_paints[0];
|
||||
assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))));
|
||||
assert_eq!(kept.stroke_width, Some(2.0));
|
||||
}
|
||||
|
||||
/// A stroke added on top of a mark replaces the stroke another mark of the series left on the graph, even when the new
|
||||
/// row shows the paint the mark already showed.
|
||||
#[test]
|
||||
fn adding_a_stroke_replaces_the_kept_stroke_with_the_marks_own() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
|
||||
s.execute("appearance.addStroke", &json!({"ids": [marks[1].0]})).unwrap();
|
||||
let kept = &spec(&s, id).series_paints[0];
|
||||
assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))));
|
||||
assert_eq!(kept.stroke_width, Some(2.0));
|
||||
}
|
||||
|
||||
/// A fill added right above a mark's active top fill replaces the fill another mark of the series left on the graph.
|
||||
#[test]
|
||||
fn adding_a_fill_above_the_active_top_fill_replaces_the_kept_fill() {
|
||||
let (mut s, id, was, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#0000ff"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [marks[1].0]})).unwrap();
|
||||
s.execute("appearance.setActiveItem", &json!({"index": fill})).unwrap();
|
||||
s.execute("appearance.addFill", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(was));
|
||||
}
|
||||
|
||||
/// A copy of a mark's top stroke replaces the stroke another mark of the series left on the graph.
|
||||
#[test]
|
||||
fn duplicating_a_marks_top_stroke_replaces_the_kept_stroke() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
|
||||
for to in [false, true] {
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
|
||||
let top = top_row(&s, marks[1], false);
|
||||
let p = if to { json!({"ids": [marks[1].0], "index": top, "to": top + 1}) } else { json!({"ids": [marks[1].0], "index": top}) };
|
||||
s.execute("appearance.duplicateItem", &p).unwrap();
|
||||
let kept = &spec(&s, id).series_paints[0];
|
||||
assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))), "{p}");
|
||||
assert_eq!(kept.stroke_width, Some(2.0), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A weight set on a stroke without paint (a line graph's marker) leaves the paint the series keeps for its line.
|
||||
#[test]
|
||||
fn a_weight_on_a_stroke_without_paint_leaves_the_kept_stroke() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "line");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let marks = series(&n, 0).children().unwrap().to_vec();
|
||||
let line = marks.iter().find(|m| m.appearance.stroke_width() > 0.0).unwrap().id;
|
||||
let marker = marks.iter().find(|m| m.appearance.stroke_width() == 0.0 && m.appearance.stroke().is_some()).unwrap().id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert!(kept.stroke.as_ref().is_some_and(|p| !p.is_none()));
|
||||
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, false), "weight": 4})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0], kept);
|
||||
}
|
||||
|
||||
/// The graph does not keep a paint set on a row of a graph design's copy inside a series. A stroke edit on a line keeps
|
||||
/// no fill, and a blend mode alone leaves what the graph keeps as it was.
|
||||
#[test]
|
||||
fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
save_design(&mut s, "Square");
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap();
|
||||
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
|
||||
assert!(matches!(copy.kind, NodeKind::Group { .. }));
|
||||
s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [copy.id.0], "index": 0, "color": "#0000ff"})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none()));
|
||||
// A path inside the design's copy is a member of the series too, and its paint is not kept either.
|
||||
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
|
||||
let mut paths = vec![];
|
||||
copy.walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) {
|
||||
paths.push(n.id);
|
||||
}
|
||||
});
|
||||
let art = paths[0];
|
||||
s.execute("appearance.setItem", &json!({"ids": [art.0], "index": top_row(&s, art, true), "color": "#00ff00"})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none()));
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "line");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let line = series(&n, 0).children().unwrap().iter().find(|m| m.appearance.stroke().is_some_and(|st| st.width > 0.0)).unwrap().clone();
|
||||
let row = line.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [line.id.0], "index": row, "color": "#0000ff"})).unwrap();
|
||||
assert!(spec(&s, id).series_paints[0].fill.is_none(), "a stroke edit on a line keeps no fill");
|
||||
|
||||
// A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it.
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
|
||||
let kept = spec(&s, id);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "multiply"})).unwrap();
|
||||
assert_eq!(spec(&s, id), kept);
|
||||
}
|
||||
|
||||
/// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out.
|
||||
#[test]
|
||||
fn a_kept_series_opacity_from_a_file_is_held_to_its_range() {
|
||||
|
||||
@@ -955,7 +955,7 @@ fn any_empty_artboards(d: &Document) -> bool {
|
||||
|
||||
fn has_empty_artboards(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if any_empty_artboards(&st.doc) { Ok(()) } else { Err("no empty artboards to delete".into()) }
|
||||
if st.any_empty_artboards(any_empty_artboards) { Ok(()) } else { Err("no empty artboards to delete".into()) }
|
||||
}
|
||||
|
||||
fn artboard_delete_empty(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::layer::{NewLayers, row_roots, rows_param};
|
||||
use super::{CommandSpec, Result, Session, bad, checked_id, cmd, has_doc, id_param, ok, switch_or};
|
||||
use super::{CommandSpec, Result, Session, bad, checked_id, cmd, has_doc, object_param, ok, switch_or};
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -336,7 +336,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.releaseToLayers";
|
||||
let build = switch_or(p, "build", false, C)?;
|
||||
let st = s.doc()?;
|
||||
let id = match id_param(p, "id") {
|
||||
let id = match object_param(p, "id", C)? {
|
||||
Some(id) => id,
|
||||
None => match st.highlighted_rows().as_slice() {
|
||||
[id] => *id,
|
||||
@@ -434,8 +434,9 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn locate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = object_param(p, "id", "layer.locate")?;
|
||||
let st = s.doc_mut()?;
|
||||
let id = id_param(p, "id").or_else(|| st.selection.objects.last().copied()).ok_or_else(|| bad("layer.locate", "nothing selected"))?;
|
||||
let id = id.or_else(|| st.selection.objects.last().copied()).ok_or_else(|| bad("layer.locate", "nothing selected"))?;
|
||||
let ancestry = st.doc.ancestry(id).ok_or(EngineError::NoNode(id))?;
|
||||
st.layer_rows = vec![id];
|
||||
st.revision += 1;
|
||||
|
||||
+443
-79
@@ -24,14 +24,15 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Component, Path};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
|
||||
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
|
||||
|
||||
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, id_param, ids_param, str_param};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, ids_param, object_param, str_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -49,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Update Links",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. One undo step → {updated: [ids], missing: [ids]}",
|
||||
"{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids]}",
|
||||
has_doc,
|
||||
update
|
||||
),
|
||||
@@ -67,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Relink",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
|
||||
has_doc,
|
||||
relink
|
||||
),
|
||||
@@ -483,23 +484,183 @@ fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result<Read> {
|
||||
Ok(Read::Document(f.link(&bytes, &g.link), src))
|
||||
}
|
||||
|
||||
/// Objects `ids` show what was read (see [`install`], [`install_document`]).
|
||||
fn install_read(d: &mut Document, ids: &[NodeId], read: Read, force: Option<Preserve>) -> Result<()> {
|
||||
/// Objects `ids` show what was read (see [`install`], [`install_document`]), and with `also`, the
|
||||
/// objects outside the layer tree that show group `also`'s content.
|
||||
fn install_read(d: &mut Document, ids: &[NodeId], read: Read, force: Option<Preserve>, also: Option<&Group>) -> Result<()> {
|
||||
let also = also.map(|g| (g.link.path.as_str(), g.key.as_str()));
|
||||
match read {
|
||||
Read::Image(r) => install(d, ids, r, force),
|
||||
Read::Document(link, src) => install_document(d, ids, &link, &src, force),
|
||||
Read::Image(r) => install(d, ids, r, force, also),
|
||||
Read::Document(link, src) => install_document(d, ids, &link, &src, force, also),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `n` a linked image (or, with `document`, a placed document) of the file at `path` showing
|
||||
/// content `key`?
|
||||
fn shows(n: &Node, (path, key): (&str, &str), document: bool) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Image(im) => !document && im.key == key && im.link.as_ref().is_some_and(|l| l.path == path),
|
||||
NodeKind::PlacedDocument(p) => document && p.key == key && p.link.path == path,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipping path of `clip`: its room as a rectangle in its frame.
|
||||
fn clip_shape((frame, room): Clip) -> PathData {
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, room.0, room.1)).transformed(frame)
|
||||
}
|
||||
|
||||
/// Remakes the linked objects outside the layer tree (in symbols, graph designs, patterns and
|
||||
/// opacity masks, where [`Document::node`] finds no ids) that `pick` holds for.
|
||||
struct Nested<'a> {
|
||||
pick: &'a dyn Fn(&Node) -> bool,
|
||||
/// The object an object becomes, and the clip it takes ([`placed_xf`]).
|
||||
make: &'a mut dyn FnMut(&Node) -> (Node, Option<Clip>),
|
||||
/// The next node id for a clip group (taken from the document after the walk).
|
||||
next: u64,
|
||||
}
|
||||
|
||||
impl Nested<'_> {
|
||||
/// Every root of `d`: the layers (whose own objects are left alone), then the art of symbols,
|
||||
/// graph designs and patterns.
|
||||
fn run(mut self, d: &mut Document) {
|
||||
let first = d.peek_next_id();
|
||||
self.next = first;
|
||||
for l in d.layers.iter_mut() {
|
||||
self.update(l, false);
|
||||
}
|
||||
for root in d.symbols.iter_mut().map(|s| &mut s.art).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) {
|
||||
self.root(root);
|
||||
}
|
||||
for root in d.patterns.iter_mut().flat_map(|p| &mut p.art) {
|
||||
self.root(root);
|
||||
}
|
||||
for _ in first..self.next {
|
||||
d.alloc_id();
|
||||
}
|
||||
}
|
||||
|
||||
/// The art at a root outside the layer tree (a symbol's, graph design's or pattern's art, or
|
||||
/// mask art).
|
||||
fn root(&mut self, n: &mut Arc<Node>) {
|
||||
if !(self.pick)(n) {
|
||||
return self.update(n, true);
|
||||
}
|
||||
let (new, clip) = (self.make)(n);
|
||||
let mut new = Arc::new(new);
|
||||
self.update(&mut new, true);
|
||||
*n = match clip {
|
||||
Some(clip) => Arc::new(self.clip_group(new, clip)),
|
||||
None => new,
|
||||
};
|
||||
}
|
||||
|
||||
/// `n` (outside the layer tree when `nested`) with the picked objects under it remade; it stays
|
||||
/// the same node when none is there, and only the nodes on the way to one are copied.
|
||||
fn update(&mut self, n: &mut Arc<Node>, nested: bool) {
|
||||
let mask = n.extras().mask.as_ref().and_then(|m| {
|
||||
let mut art = Arc::clone(&m.art);
|
||||
self.root(&mut art);
|
||||
(!Arc::ptr_eq(&art, &m.art)).then_some(art)
|
||||
});
|
||||
let mut slots: Vec<(usize, Arc<Node>)> = vec![];
|
||||
if let Some(children) = n.children() {
|
||||
// A clip group holding just the object: its clipping path changes.
|
||||
let clip_path = match (&n.kind, &children[..]) {
|
||||
(NodeKind::Group { clip: true, .. }, [c, _]) if matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c),
|
||||
_ => None,
|
||||
};
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
if !(nested && (self.pick)(c)) {
|
||||
let mut new = Arc::clone(c);
|
||||
self.update(&mut new, nested);
|
||||
if !Arc::ptr_eq(&new, c) {
|
||||
slots.push((i, new));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (new, clip) = (self.make)(c);
|
||||
let mut new = Arc::new(new);
|
||||
self.update(&mut new, true);
|
||||
match (clip, clip_path) {
|
||||
(None, _) => slots.push((i, new)),
|
||||
(Some(clip), Some(path)) if i == 1 => {
|
||||
let mut path = Node::clone(path);
|
||||
if let NodeKind::Path { path: p, .. } = &mut path.kind {
|
||||
*p = clip_shape(clip);
|
||||
}
|
||||
slots.extend([(0, Arc::new(path)), (1, new)]);
|
||||
}
|
||||
(Some(clip), _) => {
|
||||
let group = self.clip_group(new, clip);
|
||||
slots.push((i, Arc::new(group)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if mask.is_none() && slots.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(n);
|
||||
if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) {
|
||||
m.art = art;
|
||||
}
|
||||
if let Some(children) = n.children_mut() {
|
||||
for (i, c) in slots {
|
||||
if let Some(slot) = children.get_mut(i) {
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip group of `object` clipped to `clip`, as [`clip_image`] makes one in the layers.
|
||||
fn clip_group(&mut self, object: Arc<Node>, clip: Clip) -> Node {
|
||||
let (path_id, group_id) = (NodeId(self.next), NodeId(self.next + 1));
|
||||
self.next += 2;
|
||||
let none = vectorcraft_color::Paint::None;
|
||||
let mut path = Node::path(path_id, clip_shape(clip), Appearance::basic(none.clone(), none, 0.0));
|
||||
if let NodeKind::Path { clipping, .. } = &mut path.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
Node::new(group_id, NodeKind::Group { children: vec![Arc::new(path), object], clip: true })
|
||||
}
|
||||
}
|
||||
|
||||
/// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its
|
||||
/// Placement Options say (`force`: as that Preserve choice says, whatever they say).
|
||||
fn install_document(d: &mut Document, ids: &[NodeId], link: &LinkInfo, src: &super::place::document::Source, force: Option<Preserve>) -> Result<()> {
|
||||
/// Placement Options say (`force`: as that Preserve choice says, whatever they say); with `also`
|
||||
/// (a file's path and the content shown), so do the placed documents outside the layer tree that
|
||||
/// show that content.
|
||||
fn install_document(
|
||||
d: &mut Document,
|
||||
ids: &[NodeId],
|
||||
link: &LinkInfo,
|
||||
src: &super::place::document::Source,
|
||||
force: Option<Preserve>,
|
||||
also: Option<(&str, &str)>,
|
||||
) -> Result<()> {
|
||||
let mut clips = vec![];
|
||||
let (w, h) = (src.frame.width(), src.frame.height());
|
||||
let placed = |p: &PlacedDocument| {
|
||||
let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement };
|
||||
placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o)
|
||||
};
|
||||
if let Some(also) = also {
|
||||
let pick = |n: &Node| shows(n, also, true);
|
||||
let mut make = |n: &Node| {
|
||||
let mut n = n.clone();
|
||||
let mut clip = None;
|
||||
if let NodeKind::PlacedDocument(p) = &mut n.kind {
|
||||
let (xf, c) = placed(p);
|
||||
(p.key, p.width, p.height, p.xf, p.link) = (src.key.clone(), w, h, xf, link.clone());
|
||||
clip = c;
|
||||
}
|
||||
(n, clip)
|
||||
};
|
||||
Nested { pick: &pick, make: &mut make, next: 0 }.run(d);
|
||||
}
|
||||
for id in ids {
|
||||
let Some(NodeKind::PlacedDocument(p)) = d.node(*id).map(|n| &n.kind) else { continue };
|
||||
let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement };
|
||||
let (xf, clip) = placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o);
|
||||
let (xf, clip) = placed(p);
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& let NodeKind::PlacedDocument(p) = &mut n.kind
|
||||
{
|
||||
@@ -565,8 +726,8 @@ fn placed_xf(xf: Affine, old: (f64, f64), old_pt: (f64, f64), new: (f64, f64), n
|
||||
|
||||
/// Clip image `id` of `d` to `room` in `frame`: the clipping path of the clip group holding just
|
||||
/// it changes, else the image goes into a new clip group.
|
||||
fn clip_image(d: &mut Document, id: NodeId, (frame, room): Clip) -> Result<()> {
|
||||
let path = shapes::rectangle(Rect::new(0.0, 0.0, room.0, room.1)).transformed(frame);
|
||||
fn clip_image(d: &mut Document, id: NodeId, clip: Clip) -> Result<()> {
|
||||
let path = clip_shape(clip);
|
||||
let clip_path = d.parent_of(id).and_then(|p| match &d.node(p)?.kind {
|
||||
NodeKind::Group { children, clip: true } => match &children[..] {
|
||||
[c, im] if im.id == id && matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c.id),
|
||||
@@ -602,14 +763,33 @@ pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_
|
||||
let path = fileio::document_path(path, doc_path).map_err(|r| EngineError::Other(format!("the image `{path}` isn't followed: {r}")))?;
|
||||
let bytes = read_file(&path)?;
|
||||
let read = (link_info(&path, &bytes), linked_image(&bytes)?);
|
||||
install(d, &[id], read, Some(Preserve::Bounds))
|
||||
install(d, &[id], read, Some(Preserve::Bounds), None)
|
||||
}
|
||||
|
||||
/// Image objects `ids` show the file read for `link` (`img`), each placed as its Placement
|
||||
/// Options say (`force`: as that Preserve choice says, whatever they say).
|
||||
fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option<Preserve>) -> Result<()> {
|
||||
/// Options say (`force`: as that Preserve choice says, whatever they say); with `also` (a file's
|
||||
/// path and the content shown), so do the images outside the layer tree that show that content.
|
||||
fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option<Preserve>, also: Option<(&str, &str)>) -> Result<()> {
|
||||
let new_pt = super::place::pt_per_px(img.ppi);
|
||||
let mut clips = vec![];
|
||||
if let Some(also) = also {
|
||||
// Every image picked shows content `also.1`, so one resolution serves them all.
|
||||
let old_pt = file_pt(d, also.1);
|
||||
let pick = |n: &Node| shows(n, also, false);
|
||||
let mut make = |n: &Node| {
|
||||
let mut n = n.clone();
|
||||
let mut clip = None;
|
||||
if let NodeKind::Image(im) = &mut n.kind {
|
||||
let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement };
|
||||
let size = |w: u32, h: u32| (w as f64, h as f64);
|
||||
let (xf, c) = placed_xf(im.xf, size(im.width, im.height), old_pt, size(img.width, img.height), new_pt, o);
|
||||
(im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone()));
|
||||
clip = c;
|
||||
}
|
||||
(n, clip)
|
||||
};
|
||||
Nested { pick: &pick, make: &mut make, next: 0 }.run(d);
|
||||
}
|
||||
for id in ids {
|
||||
let Some(NodeKind::Image(im)) = d.node(*id).map(|n| &n.kind) else { continue };
|
||||
let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement };
|
||||
@@ -678,6 +858,9 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
|
||||
// of other keys linking to the same file share them, rather than each reading, decoding and
|
||||
// keeping a copy of its own.
|
||||
let mut read_images: BTreeMap<String, (ImageBlob, String)> = BTreeMap::new();
|
||||
// (the path as it was, the content shown, a placed document?, the link now) of each file found
|
||||
// away from its path or read again.
|
||||
let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![];
|
||||
for g in groups(d, None) {
|
||||
let row = rows(std::iter::once(&g), doc_path);
|
||||
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
|
||||
@@ -685,7 +868,8 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
|
||||
match probe(&g, doc_path) {
|
||||
(Status::Missing, _) => out.missing.extend(row),
|
||||
(Status::Modified, Some(mut f)) if update || unread => {
|
||||
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread.then_some(Preserve::Bounds))) {
|
||||
let force = unread.then_some(Preserve::Bounds);
|
||||
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, force, Some(&g))) {
|
||||
Ok(()) if unread => {}
|
||||
Ok(()) => out.updated.extend(row),
|
||||
Err(_) => out.missing.extend(row),
|
||||
@@ -697,7 +881,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
|
||||
let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open")) } else { None };
|
||||
match read {
|
||||
Some(Ok(read)) => {
|
||||
if install_read(d, &g.ids, read, Some(Preserve::Bounds)).is_err() {
|
||||
if install_read(d, &g.ids, read, Some(Preserve::Bounds), Some(&g)).is_err() {
|
||||
out.missing.extend(row);
|
||||
}
|
||||
}
|
||||
@@ -709,6 +893,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
|
||||
};
|
||||
if link != g.link {
|
||||
set_links(d, &g.ids, &link);
|
||||
moved.push((g.link.path.clone(), g.key.clone(), true, link));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -720,6 +905,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
|
||||
let link = f.link_hashed(hash, &g.link);
|
||||
if link != g.link {
|
||||
set_links(d, &g.ids, &link);
|
||||
moved.push((g.link.path.clone(), g.key.clone(), false, link));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -745,11 +931,34 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
|
||||
};
|
||||
if link != g.link {
|
||||
set_links(d, &g.ids, &link);
|
||||
moved.push((g.link.path.clone(), g.key.clone(), false, link));
|
||||
}
|
||||
}
|
||||
(Status::Ok, None) => {}
|
||||
}
|
||||
}
|
||||
// The objects in symbols, graph designs, patterns and opacity masks that show the same file and
|
||||
// content, which have no ids here, whatever else their links hold.
|
||||
if !moved.is_empty() {
|
||||
let mut by_path: BTreeMap<String, Vec<(String, bool, LinkInfo)>> = BTreeMap::new();
|
||||
for (path, key, document, new) in moved {
|
||||
by_path.entry(path).or_default().push((key, document, new));
|
||||
}
|
||||
let new = |n: &Node| {
|
||||
let (l, key, document) = linked(n)?;
|
||||
let m = by_path.get(&l.path)?;
|
||||
m.iter().find(|(k, doc, _)| k == key && *doc == document.is_some()).map(|(_, _, new)| new)
|
||||
};
|
||||
let pick = |n: &Node| new(n).is_some();
|
||||
let mut make = |n: &Node| {
|
||||
let mut out = n.clone();
|
||||
if let (Some(link), Some(l)) = (new(n), link_mut(&mut out)) {
|
||||
*l = link.clone();
|
||||
}
|
||||
(out, None)
|
||||
};
|
||||
Nested { pick: &pick, make: &mut make, next: 0 }.run(d);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -772,35 +981,31 @@ fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) {
|
||||
}
|
||||
|
||||
/// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open
|
||||
/// documents: read again from the new file, each as one Update Links step of its document.
|
||||
/// documents, in the layers, symbols, graph designs, patterns and opacity masks: read again from
|
||||
/// the new file, each as one Update Links step of its document.
|
||||
pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) {
|
||||
let path = absolute_path(path);
|
||||
let targets: Vec<(usize, Vec<u64>)> = s
|
||||
let targets: Vec<usize> = s
|
||||
.documents()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, st)| st.uid != saved)
|
||||
.filter_map(|(i, st)| {
|
||||
let mut ids = vec![];
|
||||
st.doc.walk(|n| {
|
||||
if let NodeKind::PlacedDocument(p) = &n.kind
|
||||
&& p.link.path == path
|
||||
{
|
||||
ids.push(n.id.0);
|
||||
}
|
||||
});
|
||||
(!ids.is_empty()).then_some((i, ids))
|
||||
.filter(|(_, st)| {
|
||||
let mut placed = false;
|
||||
st.doc.visit_placed(|_, p| placed |= p.link.path == path);
|
||||
placed
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Each document is updated as the active one, without switching tools.
|
||||
let back = s.active;
|
||||
for (i, ids) in targets {
|
||||
for i in targets {
|
||||
s.active = Some(i);
|
||||
// A file that can't be read leaves what its objects show: links.check reports it.
|
||||
let _ = update(s, &json!({ "ids": ids }));
|
||||
let _ = update_where(s, None, Some(&path));
|
||||
}
|
||||
s.active = back;
|
||||
}
|
||||
@@ -826,31 +1031,45 @@ fn relative_path(target: &Path, base: &Path) -> Option<String> {
|
||||
}
|
||||
|
||||
/// `d` as saved to `dest`: the links' paths relative to its folder written in; `None` when they
|
||||
/// are already.
|
||||
/// are already. Links in the layers, symbols, graph designs, patterns and opacity masks are all set.
|
||||
pub fn with_relative_paths(d: &Document, dest: &str) -> Option<Document> {
|
||||
let dest = absolute_path(dest);
|
||||
let dir = Path::new(&dest).parent()?;
|
||||
let mut changes = vec![];
|
||||
d.walk(|n| {
|
||||
if let Some((l, _, _)) = linked(n) {
|
||||
let r = relative_path(Path::new(&l.path), dir);
|
||||
if r != l.relative {
|
||||
changes.push((n.id, r));
|
||||
}
|
||||
}
|
||||
});
|
||||
if changes.is_empty() {
|
||||
let relative = |l: &LinkInfo| relative_path(Path::new(&l.path), dir);
|
||||
let outdated = |l: &LinkInfo| relative(l) != l.relative;
|
||||
let mut changed = false;
|
||||
d.visit_images(|_, im| changed |= im.link.as_ref().is_some_and(outdated));
|
||||
d.visit_placed(|_, p| changed |= outdated(&p.link));
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
let mut d = d.clone();
|
||||
for (id, r) in changes {
|
||||
if let Some(l) = d.node_mut(id).and_then(link_mut) {
|
||||
l.relative = r;
|
||||
}
|
||||
}
|
||||
d.update_links_where(outdated, |l| l.relative = relative(l));
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// Sets each link of `d`, a copy of the document saved at `doc_path` that has no file of its own,
|
||||
/// to the path where that document finds its file, so the copy finds the same files.
|
||||
/// Links in the layers, symbols, graph designs, patterns and opacity masks are all set.
|
||||
pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) {
|
||||
// The new path of each link by what `locate` reads of it: its path, relative path and name.
|
||||
let mut found: BTreeMap<(String, Option<String>), Option<String>> = BTreeMap::new();
|
||||
let mut look = |l: &LinkInfo| {
|
||||
found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| locate(l, Some(doc_path)).map(|f| f.path));
|
||||
};
|
||||
d.visit_images(|_, im| im.link.iter().for_each(&mut look));
|
||||
d.visit_placed(|_, p| look(&p.link));
|
||||
found.retain(|(path, _), f| f.as_ref().is_some_and(|f| f != path));
|
||||
if found.is_empty() {
|
||||
return;
|
||||
}
|
||||
d.update_links(|l| {
|
||||
if let Some(Some(f)) = found.get(&(l.path.clone(), l.relative.clone())) {
|
||||
l.path = f.clone();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -876,28 +1095,92 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
update_where(s, ids_param(p, "ids"), None)
|
||||
}
|
||||
|
||||
/// Update Links in the active document of `s` for objects `ids` (default: the linked files that
|
||||
/// changed or show their preview), or with `path`, for the placed documents of the file at `path`
|
||||
/// whatever their status. The objects in symbols, graph designs, patterns and opacity masks that
|
||||
/// show the same content are read again too, and have no ids to report.
|
||||
fn update_where(s: &mut Session, ids: Option<Vec<NodeId>>, path: Option<&str>) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let ids = ids_param(p, "ids");
|
||||
let (mut reads, mut missing) = (vec![], vec![]);
|
||||
for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| !g.ids.is_empty()) {
|
||||
let asked = ids.is_some() || path.is_some();
|
||||
for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| path.is_none_or(|p| g.document.is_some() && g.link.path == p)) {
|
||||
match probe(&g, st.path.as_deref()) {
|
||||
(Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)),
|
||||
// Unchanged and showing the file: nothing to read unless asked for by id.
|
||||
(Status::Ok, Some(_)) if ids.is_none() && !stale(&st.doc, &g) => {}
|
||||
// Unchanged and showing the file: nothing to read unless asked for.
|
||||
(Status::Ok, Some(_)) if !asked && !stale(&st.doc, &g) => {}
|
||||
// Unreadable as an image (or a document) counts as missing.
|
||||
(_, Some(mut f)) => match read_for(&mut f, &g, "links.update") {
|
||||
Ok(read) => reads.push((g.ids, read)),
|
||||
Ok(read) => reads.push((g, read)),
|
||||
Err(_) => missing.extend(ids_json(&g.ids)),
|
||||
},
|
||||
}
|
||||
}
|
||||
let updated: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
|
||||
let updated: Vec<u64> = reads.iter().flat_map(|(g, _)| ids_json(&g.ids)).collect();
|
||||
if !reads.is_empty() {
|
||||
s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, None)))?;
|
||||
s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(g, read)| install_read(d, &g.ids, read, None, Some(&g))))?;
|
||||
}
|
||||
Ok(json!({ "updated": updated, "missing": missing }))
|
||||
}
|
||||
|
||||
/// Which links `links.relink` points at the new file (the Replace choice of Relink).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Scope {
|
||||
/// Each link its own file (`paths`).
|
||||
Each,
|
||||
/// The objects given or selected.
|
||||
Selected,
|
||||
/// Every object in the layers linked to the same file as one given or selected.
|
||||
All,
|
||||
/// The objects given or selected, then every other missing link whose file name is in the new
|
||||
/// file's folder.
|
||||
MissingInFolder,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
fn from_id(id: &str) -> Option<Self> {
|
||||
match id {
|
||||
"each" => Some(Scope::Each),
|
||||
"selected" => Some(Scope::Selected),
|
||||
"all" => Some(Scope::All),
|
||||
"missingInFolder" => Some(Scope::MissingInFolder),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The error for `scope` and `paths` given one without the other, or `paths` beside ids, path or
|
||||
/// folder.
|
||||
fn each_mismatch(cmd: &str) -> EngineError {
|
||||
bad(cmd, "scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder")
|
||||
}
|
||||
|
||||
/// `links.relink {scope: "each", paths}`: each entry's ids with its absolute file, entries naming
|
||||
/// one file merged. A malformed entry, an id in two entries or an empty list is an error.
|
||||
fn each_paths(p: &Value, cmd: &str) -> Result<Vec<(Vec<NodeId>, String)>> {
|
||||
let entries = p.get("paths").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(|| each_mismatch(cmd))?;
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut out: Vec<(Vec<NodeId>, String)> = vec![];
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let malformed = || bad(cmd, format!("paths entry {i} needs path and ids (object ids no other entry has)"));
|
||||
let path = e.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(malformed)?;
|
||||
let ids = e.get("ids").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(malformed)?;
|
||||
let mut these = vec![];
|
||||
for v in ids {
|
||||
let id = v.as_u64().map(NodeId).filter(|id| seen.insert(*id)).ok_or_else(malformed)?;
|
||||
these.push(id);
|
||||
}
|
||||
let path = absolute_path(path);
|
||||
match out.iter_mut().find(|(_, p)| *p == path) {
|
||||
Some((ids, _)) => ids.extend(these),
|
||||
None => out.push((these, path)),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.relink";
|
||||
// How the new file takes the image's place this time (default: its Placement Options).
|
||||
@@ -905,13 +1188,26 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => None,
|
||||
Some(v) => Some(Preserve::from_id(v).ok_or_else(|| bad(C, format!("preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill")))?),
|
||||
};
|
||||
let scope = match p.get("scope").filter(|v| !v.is_null()) {
|
||||
None => Scope::Selected,
|
||||
Some(v) => v.as_str().and_then(Scope::from_id).ok_or_else(|| bad(C, format!("scope {v} is not each|selected|all|missingInFolder")))?,
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let folder_param = str_param(p, "folder");
|
||||
match scope {
|
||||
Scope::Each if p.get("path").is_some() || folder_param.is_some() || p.get("ids").is_some() => return Err(each_mismatch(C)),
|
||||
Scope::Selected | Scope::All | Scope::MissingInFolder if p.get("paths").is_some() => return Err(each_mismatch(C)),
|
||||
Scope::MissingInFolder if folder_param.is_some() => {
|
||||
return Err(bad(C, "scope missingInFolder takes path: the folder of that file is searched for the other missing links"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let each = if scope == Scope::Each { Some(each_paths(p, C)?) } else { None };
|
||||
// With copy: the saved document's folder, which the file is copied into.
|
||||
let copy_to = match (bool_or(p, "copy", false), str_param(p, "path")) {
|
||||
let copy_to = match (bool_or(p, "copy", false), str_param(p, "path").is_some() || each.is_some()) {
|
||||
(false, _) => None,
|
||||
(true, None) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")),
|
||||
(true, Some(_)) => {
|
||||
(true, false) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")),
|
||||
(true, true) => {
|
||||
let doc = st.path.as_deref().map(absolute_path).ok_or_else(|| bad(C, "save the document first: copy puts the file in its folder"))?;
|
||||
Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(|| bad(C, format!("{doc} has no folder")))?)
|
||||
}
|
||||
@@ -924,9 +1220,10 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let ids: Vec<NodeId> = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => {
|
||||
let mut ids: Vec<NodeId> = match (&each, ids_param(p, "ids")) {
|
||||
(Some(each), _) => each.iter().flat_map(|(ids, _)| ids.iter().copied()).collect(),
|
||||
(None, Some(ids)) => ids,
|
||||
(None, None) => {
|
||||
let selected: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect();
|
||||
match folder_param {
|
||||
Some(_) if selected.is_empty() => {
|
||||
@@ -942,15 +1239,37 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select the images to relink, or pass ids"));
|
||||
}
|
||||
// All Instances: the other objects in the layers linked to the same files.
|
||||
if scope == Scope::All {
|
||||
let paths: BTreeSet<&str> = ids.iter().filter_map(|id| image(id).flatten()).map(|l| l.path.as_str()).collect();
|
||||
let given: BTreeSet<NodeId> = ids.iter().copied().collect();
|
||||
let mut more = vec![];
|
||||
st.doc.walk(|n| {
|
||||
if let Some((l, _, _)) = linked(n)
|
||||
&& paths.contains(l.path.as_str())
|
||||
&& !given.contains(&n.id)
|
||||
{
|
||||
more.push(n.id);
|
||||
}
|
||||
});
|
||||
ids.extend(more);
|
||||
}
|
||||
if copy_to.is_some() && ids.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) {
|
||||
return Err(bad(C, "copy takes images only: relink placed documents where their files are"));
|
||||
}
|
||||
// (ids, the file) per file to read.
|
||||
let mut files: Vec<(Vec<NodeId>, String)> = vec![];
|
||||
let mut not_found = vec![];
|
||||
match (str_param(p, "path"), folder_param) {
|
||||
(Some(path), _) => files.push((ids, absolute_path(path))),
|
||||
(None, Some(dir)) => {
|
||||
match (each, str_param(p, "path"), folder_param) {
|
||||
(Some(each), _, _) => files = each,
|
||||
(None, Some(path), _) if scope == Scope::MissingInFolder => {
|
||||
let path = absolute_path(path);
|
||||
let given: BTreeSet<NodeId> = ids.iter().copied().collect();
|
||||
files.push((ids, path));
|
||||
missing_in_folder(st, &given, copy_to.is_some(), &mut files, &mut not_found);
|
||||
}
|
||||
(None, Some(path), _) => files.push((ids, absolute_path(path))),
|
||||
(None, None, Some(dir)) => {
|
||||
for id in ids {
|
||||
let Some(link) = image(&id).flatten() else { continue };
|
||||
let path = absolute_path(&Path::new(dir).join(link.name()).to_string_lossy());
|
||||
@@ -961,10 +1280,10 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None) => return Err(bad(C, "give path (a file) or folder")),
|
||||
(None, None, None) => return Err(bad(C, "give path (a file) or folder")),
|
||||
}
|
||||
let mut reads = vec![];
|
||||
let (mut copied, mut kept) = (vec![], vec![]);
|
||||
// Every file is read and checked before any is copied.
|
||||
let mut checked = vec![];
|
||||
for (ids, path) in files {
|
||||
let bytes = read_file(&path)?;
|
||||
// Images show the file; placed documents each show their artboard or page of it.
|
||||
@@ -988,20 +1307,28 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for ((page, bounding, page_box), ids) in documents {
|
||||
sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?));
|
||||
}
|
||||
let to = match ©_to {
|
||||
Some(dir) => {
|
||||
let (to, new) = copy_into_folder(&path, &bytes, dir)?;
|
||||
// The bytes are kept only to be copied, the file as it was checked.
|
||||
let link = link_info(&path, &bytes);
|
||||
let bytes = copy_to.is_some().then_some(bytes);
|
||||
checked.push((path, link, bytes, images, img, sources));
|
||||
}
|
||||
let mut reads = vec![];
|
||||
let (mut copied, mut kept): (Vec<Value>, Vec<Value>) = (vec![], vec![]);
|
||||
for (path, link, bytes, images, img, sources) in checked {
|
||||
let link = match (©_to, bytes) {
|
||||
(Some(dir), Some(bytes)) => {
|
||||
let (to, new) = copy_or_remove(&path, &bytes, dir, &copied)?;
|
||||
let link = link_info(&to, &bytes);
|
||||
let pair = json!({ "from": path, "to": to });
|
||||
if new {
|
||||
copied.push(pair)
|
||||
} else {
|
||||
kept.push(pair)
|
||||
}
|
||||
to
|
||||
link
|
||||
}
|
||||
None => path,
|
||||
_ => link,
|
||||
};
|
||||
let link = link_info(&to, &bytes);
|
||||
if let Some(img) = img {
|
||||
reads.push((images, Read::Image((link.clone(), img))));
|
||||
}
|
||||
@@ -1011,7 +1338,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let relinked: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
|
||||
if !reads.is_empty() {
|
||||
s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve)))?;
|
||||
s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve, None)))?;
|
||||
}
|
||||
not_found.sort();
|
||||
not_found.dedup();
|
||||
@@ -1023,6 +1350,41 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `links.relink {scope: "missingInFolder"}`: the other missing links of `st` (not `given`; only
|
||||
/// images when `images_only`, as copy takes), each added to `files` with the file of its name in
|
||||
/// the folder of the first file there; names not there go to `not_found`.
|
||||
fn missing_in_folder(
|
||||
st: &crate::DocState,
|
||||
given: &BTreeSet<NodeId>,
|
||||
images_only: bool,
|
||||
files: &mut Vec<(Vec<NodeId>, String)>,
|
||||
not_found: &mut Vec<String>,
|
||||
) {
|
||||
let Some(dir) = files.first().and_then(|(_, p)| Path::new(p).parent()).map(Path::to_path_buf) else { return };
|
||||
for g in groups(&st.doc, None).into_iter().filter(|g| !(images_only && g.document.is_some())) {
|
||||
let rest: Vec<NodeId> = g.ids.iter().copied().filter(|id| !given.contains(id)).collect();
|
||||
if rest.is_empty() || probe(&g, st.path.as_deref()).0 != Status::Missing {
|
||||
continue;
|
||||
}
|
||||
let found = absolute_path(&dir.join(g.link.name()).to_string_lossy());
|
||||
match files.iter_mut().find(|(_, p)| *p == found) {
|
||||
Some((ids, _)) => ids.extend(rest),
|
||||
None if file_stamp(&found).is_some() => files.push((rest, found)),
|
||||
None => not_found.push(g.link.name().to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`copy_into_folder`]; when it fails, the copies `copied` (`{from, to}`) the same call made
|
||||
/// before are removed, since nothing is relinked.
|
||||
fn copy_or_remove(from: &str, bytes: &[u8], dir: &Path, copied: &[Value]) -> Result<(String, bool)> {
|
||||
copy_into_folder(from, bytes, dir).inspect_err(|_| {
|
||||
for to in copied.iter().filter_map(|pair| pair.get("to").and_then(Value::as_str)) {
|
||||
let _ = std::fs::remove_file(to);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `links.relink {copy}`: put the file at `from` (`bytes`, as just read) into the folder `dir`,
|
||||
/// never replacing a file there ([`fileio::copy_new`]) → where it is now, and whether it was
|
||||
/// copied (`false`: a file there with the same contents, of its name or a numbered one, is used).
|
||||
@@ -1071,7 +1433,9 @@ fn selected_images(st: &crate::DocState, keep: impl Fn(&ImageObject) -> bool) ->
|
||||
|
||||
/// `id`, else the first selected image.
|
||||
fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
let id = id_param(p, "id").or_else(|| selected_images(st, |_| true).first().copied()).ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
|
||||
let id = object_param(p, "id", cmd)?
|
||||
.or_else(|| selected_images(st, |_| true).first().copied())
|
||||
.ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
|
||||
match image_of(&st.doc, id) {
|
||||
Some(_) => Ok(id),
|
||||
None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)),
|
||||
@@ -1100,7 +1464,7 @@ fn placement_of(d: &Document, id: NodeId) -> Option<PlacementOptions> {
|
||||
/// `id`, else the first selected image or placed document.
|
||||
fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
let first = || st.selection.objects.iter().copied().find(|id| placement_of(&st.doc, *id).is_some());
|
||||
let id = id_param(p, "id").or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
|
||||
let id = object_param(p, "id", cmd)?.or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
|
||||
match placement_of(&st.doc, id) {
|
||||
Some(_) => Ok(id),
|
||||
None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)),
|
||||
@@ -1270,7 +1634,7 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !done.is_empty() || !documents.is_empty() {
|
||||
s.edit("Embed", |d, _| {
|
||||
for (ids, read) in reads {
|
||||
install(d, &ids, read, Some(Preserve::Bounds))?;
|
||||
install(d, &ids, read, Some(Preserve::Bounds), None)?;
|
||||
}
|
||||
for id in &done {
|
||||
if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) {
|
||||
@@ -1327,7 +1691,7 @@ fn unembed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let img = linked_image(&bytes)?;
|
||||
write_file(&path, &bytes)?;
|
||||
let read = (link_info(&path, &bytes), img);
|
||||
s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds)))?;
|
||||
s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds), None))?;
|
||||
Ok(json!({ "id": id.0, "path": path }))
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ use super::edit::selected_roots;
|
||||
use super::expand::gradient_sampler;
|
||||
use super::pathops::Beside;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, check_switches, cmd, color_param, f64_or, f64_req, finite_numbers,
|
||||
has_doc, has_multi, has_selection, id_param, named_ids, number_or, ok, str_param, switch_or,
|
||||
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_param, f64_or, f64_req, finite_numbers, has_doc, has_multi,
|
||||
has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, text_param,
|
||||
};
|
||||
|
||||
mod blend;
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::common::{
|
||||
use super::mesh::index_param;
|
||||
use super::{
|
||||
Affine, Anchor, Appearance, Arc, Beside, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, Document, EngineError, Node, NodeId, NodeKind,
|
||||
Paint, PathData, Point, Result, Session, Spine, Value, Vec2, bad, f64_req, id_param, json, live, number_or, ok, str_param, switch_or,
|
||||
Paint, PathData, Point, Result, Session, Spine, Value, Vec2, bad, f64_req, json, live, number_or, ok, str_param, switch_or,
|
||||
};
|
||||
|
||||
/// The first blend among `roots`.
|
||||
@@ -160,7 +160,7 @@ fn edit_spine<T>(
|
||||
label: &str,
|
||||
f: impl FnOnce(&mut vectorcraft_geom::SubPath, &mut Vec<usize>, &mut [Arc<Node>]) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
let id = match id_param(p, "id") {
|
||||
let id = match crate::cmd::object_param(p, "id", cmd)? {
|
||||
Some(id) => id,
|
||||
None => *selected_of(s, is_blend).first().ok_or_else(|| bad(cmd, "select a blend or give `id`"))?,
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make with Warp…",
|
||||
["Object", "Envelope Distort"],
|
||||
Some("Cmd+Alt+Shift+W"),
|
||||
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected) → {id}",
|
||||
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
|
||||
has_selection,
|
||||
env_make_warp
|
||||
),
|
||||
@@ -145,7 +145,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make with Mesh…",
|
||||
["Object", "Envelope Distort"],
|
||||
Some("Cmd+Alt+M"),
|
||||
"{ids?, rows?: 1..50 (4), cols?: 1..50 (4), columns?: as cols} envelope the selected objects with a mesh of rows × cols patches → {id}",
|
||||
"{ids?, rows?: 1..50 (4), cols?: 1..50 (4), columns?: as cols} envelope the selected objects with a mesh of rows × cols patches over their bounds (type measured by its glyph outlines) → {id}",
|
||||
has_selection,
|
||||
env_make_mesh
|
||||
),
|
||||
@@ -154,7 +154,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make with Top Object",
|
||||
["Object", "Envelope Distort"],
|
||||
Some("Cmd+Alt+C"),
|
||||
"{ids?} the topmost selected path becomes the envelope of the others → {id}",
|
||||
"{ids?} the topmost selected path becomes the envelope of the others (type maps from its glyph outline bounds) → {id}",
|
||||
has_multi,
|
||||
env_make_top
|
||||
),
|
||||
@@ -172,7 +172,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reset with Mesh…",
|
||||
["Object", "Envelope Distort"],
|
||||
None,
|
||||
"{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content), columns?: as cols} make the selected envelopes mesh envelopes, keeping their content",
|
||||
"{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content, type measured by its glyph outlines), columns?: as cols} make the selected envelopes mesh envelopes, keeping their content",
|
||||
has_envelope,
|
||||
env_reset_mesh
|
||||
),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::super::object::choice;
|
||||
use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wrap};
|
||||
use super::mesh::{handle_param, index_param, whole};
|
||||
use super::{
|
||||
Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint,
|
||||
PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, check_switches, f64_or, f64_req, id_param, json, live, ok, str_param,
|
||||
switch_or,
|
||||
PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, id_param, json, live, ok, switch_or, text_param,
|
||||
};
|
||||
|
||||
// ---------- Envelope ----------
|
||||
@@ -17,7 +17,7 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop
|
||||
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur {
|
||||
(style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0);
|
||||
}
|
||||
if let Some(s) = str_param(p, "style") {
|
||||
if let Some(s) = text_param(p, "style", cmd)? {
|
||||
let s = s.strip_prefix("warp.").unwrap_or(s);
|
||||
if live::WarpStyle::from_id(s).is_none() {
|
||||
return Err(bad(cmd, format!("unknown warp style `{s}`")));
|
||||
@@ -28,10 +28,9 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop
|
||||
h = f64_or(p, "h", f64_or(p, "horizontalDistortion", h)).clamp(-100.0, 100.0);
|
||||
v = f64_or(p, "v", f64_or(p, "verticalDistortion", v)).clamp(-100.0, 100.0);
|
||||
let given = switch_or(p, "horizontal", horizontal, cmd)?;
|
||||
horizontal = match str_param(p, "orientation") {
|
||||
Some("vertical") => false,
|
||||
Some("horizontal") => true,
|
||||
_ => given,
|
||||
horizontal = match choice(p, cmd, "orientation", &["horizontal", "vertical"])? {
|
||||
Some(o) => o == "horizontal",
|
||||
None => given,
|
||||
};
|
||||
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
|
||||
}
|
||||
@@ -85,7 +84,7 @@ pub(super) fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = roots_param(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect();
|
||||
let src = live::nodes_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?;
|
||||
let src = live::envelope_content_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?;
|
||||
let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols), handles: vec![] };
|
||||
let defaults = envelope_defaults(s);
|
||||
let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?;
|
||||
@@ -185,7 +184,7 @@ fn envelope_options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Va
|
||||
|
||||
/// `cur` with the options `p` sets.
|
||||
fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result<EnvelopeOptions> {
|
||||
let preserve_shape = match str_param(p, "preserveShape") {
|
||||
let preserve_shape = match text_param(p, "preserveShape", cmd)? {
|
||||
Some(v) => PreserveShape::parse(v).ok_or_else(|| bad(cmd, "preserveShape must be clippingMask|transparency"))?,
|
||||
None => cur.preserve_shape,
|
||||
};
|
||||
@@ -289,8 +288,8 @@ pub(super) fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
pub(super) fn env_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.envelope.options";
|
||||
check_numbers(p, &["fidelity"], C)?;
|
||||
check_numbers(p, WARP_NUMBERS, C)?;
|
||||
check_switches(p, &["horizontal"], C)?;
|
||||
// The warp keys are checked whatever is selected: with nothing or only mesh envelopes, too.
|
||||
warp_kind(p, C, None)?;
|
||||
let fid = p.get("fidelity").and_then(Value::as_f64);
|
||||
if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) {
|
||||
return Err(bad(C, "fidelity must be 0..100"));
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, MaskEdit, Node, NodeId, NodeKind, Selection};
|
||||
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, id_param, switch_or};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, object_param, switch_or};
|
||||
|
||||
/// Name of the temporary mask-editing layer.
|
||||
pub const MASK_EDIT_LAYER: &str = "Opacity Mask Editing";
|
||||
@@ -117,7 +117,7 @@ fn enter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.doc.mask_edit.is_some() {
|
||||
return Err(bad(C, "already editing an opacity mask"));
|
||||
}
|
||||
let id = id_param(p, "id").or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
|
||||
let id = object_param(p, "id", C)?.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
|
||||
let layer = start(s, id)?;
|
||||
Ok(json!({ "layer": layer.0 }))
|
||||
}
|
||||
@@ -136,6 +136,7 @@ fn start(s: &mut Session, id: NodeId) -> Result<NodeId> {
|
||||
|
||||
fn view_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "transparency.viewOpacityMask";
|
||||
let given = object_param(p, "id", C)?;
|
||||
let st = s.doc()?;
|
||||
let editing = st.doc.mask_edit.map(|m| m.object);
|
||||
if !switch_or(p, "on", st.shown_mask().is_none(), C)? {
|
||||
@@ -144,7 +145,7 @@ fn view_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.revision += 1;
|
||||
return Ok(json!({ "on": false, "id": id.map(|i| i.0) }));
|
||||
}
|
||||
let id = id_param(p, "id").or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
|
||||
let id = given.or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
|
||||
match editing {
|
||||
Some(e) if e != id => return Err(bad(C, "already editing another object's opacity mask")),
|
||||
Some(_) => {}
|
||||
|
||||
@@ -106,7 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reset Bounding Box",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{} square the bounding box of the selected objects to the page again after a rotation (their geometry stays; one undo step, none when no box is rotated) → {changed: objects reset}",
|
||||
"{} square the bounding box of the selected objects, and of every object inside them (the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks), to the page again after a rotation (their geometry stays; one undo step, none when no box is rotated) → {changed: objects reset, the objects inside included}",
|
||||
has_selection,
|
||||
reset_bounding_box
|
||||
),
|
||||
@@ -178,7 +178,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rearrange All Artboards…",
|
||||
["Object", "Artboards"],
|
||||
None,
|
||||
"{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (each object of a layer or sublayer moves with the artboard its center is on; locked and hidden art, and art on a locked artboard, only with prefs moveLockedWithArtboard), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's; a locked artboard stays where it is → {artboards, rows, columns}",
|
||||
"{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (each object of a layer or sublayer moves with the artboard its center is on; locked and hidden art, and art on a locked artboard, only with lockedAndHidden), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (default: prefs moveLockedWithArtboard), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's; a locked artboard stays where it is → {artboards, rows, columns}",
|
||||
has_doc,
|
||||
rearrange_artboards
|
||||
),
|
||||
@@ -223,21 +223,63 @@ pub(crate) fn squash(s: &mut Session, from: usize, label: &str) {
|
||||
|
||||
// ---------- Reset Bounding Box ----------
|
||||
|
||||
/// Squares each selected object and every object inside it: the children of groups, compound paths,
|
||||
/// clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks.
|
||||
fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let turned: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.bbox_angle != 0.0)).collect();
|
||||
if !turned.is_empty() {
|
||||
let mut changed = 0;
|
||||
let squared: Vec<(NodeId, Node)> = ids
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let (n, count) = squared(d.node(id)?);
|
||||
changed += count;
|
||||
Some((id, n?))
|
||||
})
|
||||
.collect();
|
||||
if !squared.is_empty() {
|
||||
s.edit("Reset Bounding Box", |d, _| {
|
||||
for id in &turned {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.bbox_angle = 0.0;
|
||||
for (id, n) in squared {
|
||||
if let Some(slot) = d.node_mut(id) {
|
||||
*slot = n;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "changed": turned.len() }))
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
/// A copy of `n` with the angle of every object in its subtree set to 0, or `None` when no box in
|
||||
/// it stands at an angle, and the number of objects squared. The subtree takes in children and the
|
||||
/// art of linked opacity masks (an unlinked mask's art turns on its own); layers have no box. Only
|
||||
/// the nodes on the way to a turned object are copied.
|
||||
fn squared(n: &Node) -> (Option<Node>, usize) {
|
||||
let mut out = (!n.is_layer() && n.bbox_angle != 0.0).then(|| {
|
||||
let mut c = n.clone();
|
||||
c.bbox_angle = 0.0;
|
||||
c
|
||||
});
|
||||
let mut count = usize::from(out.is_some());
|
||||
for (i, c) in n.children().into_iter().flatten().enumerate() {
|
||||
let (child, k) = squared(c);
|
||||
count += k;
|
||||
if let Some(child) = child
|
||||
&& let Some(slot) = out.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i))
|
||||
{
|
||||
*slot = Arc::new(child);
|
||||
}
|
||||
}
|
||||
if let Some(m) = n.extras().mask.as_ref().filter(|m| m.linked) {
|
||||
let (art, k) = squared(&m.art);
|
||||
count += k;
|
||||
if let Some(art) = art
|
||||
&& let Some(m) = out.get_or_insert_with(|| n.clone()).extras_if_any().and_then(|e| e.mask.as_mut())
|
||||
{
|
||||
m.art = Arc::new(art);
|
||||
}
|
||||
}
|
||||
(out, count)
|
||||
}
|
||||
|
||||
// ---------- Lock / Hide ----------
|
||||
@@ -1274,7 +1316,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
||||
let move_art = switch_or(p, "moveArtwork", true, CMD)?;
|
||||
check_switches(p, &["patterns"], CMD)?;
|
||||
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
||||
let locked_and_hidden = super::panelcmds::locked_and_hidden(s, p, move_art, CMD)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect();
|
||||
@@ -1291,7 +1333,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// With moveArtwork, each top-level object (a child of a layer or sublayer) moves with the
|
||||
// artboard its center is on, with symbol instances measured by their art as
|
||||
// `Document::art_on_artboard` measures them; locked and hidden art, and art on a locked
|
||||
// artboard, moves only when prefs moveLockedWithArtboard is on.
|
||||
// artboard, moves only with `locked_and_hidden`.
|
||||
fn objects<'a>(n: &'a Node, all: bool, out: &mut Vec<&'a Node>) {
|
||||
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
|
||||
if c.is_layer() { objects(c, all, out) } else { out.push(c) }
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::edit::{duplicate_in, outermost, selected_roots, selected_roots_to_tra
|
||||
use super::opacitymask::percent;
|
||||
use super::{
|
||||
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_multi, has_selection,
|
||||
has_selection_or_guides, id_param, ids_param, matrix_param, ok, point_param, str_param, targets,
|
||||
has_selection_or_guides, ids_param, matrix_param, object_param, ok, point_param, str_param, targets,
|
||||
};
|
||||
use crate::{EngineError, LastTransform};
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ fn measure(s: &Session, p: &Value, cmd: &str) -> Result<Measure> {
|
||||
Ok(Measure { preview, point: s.prefs.align_glyph_bounds_point, area: s.prefs.align_glyph_bounds_area })
|
||||
}
|
||||
|
||||
/// Param `key` of the Align commands, Shear, Unlock All, Show All and `text.setFormat`'s lists: one
|
||||
/// Param `key` of the Align commands, Shear, Unlock All, Show All, a warp's `orientation` and `text.setFormat`'s lists: one
|
||||
/// of `values`, or `None` when it is missing or null. Any other value is an error that lists `values`.
|
||||
pub(crate) fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result<Option<&'a str>> {
|
||||
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::align::choice;
|
||||
use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, id_param, json, ok, selected_roots};
|
||||
use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, json, object_param, ok, selected_roots};
|
||||
|
||||
pub(super) enum Arrange {
|
||||
Front,
|
||||
@@ -293,7 +293,7 @@ pub(super) fn show_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(super) fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id")
|
||||
let id = object_param(p, "id", "object.isolate")?
|
||||
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
|
||||
.ok_or_else(|| bad("object.isolate", "missing id"))?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
|
||||
@@ -128,7 +128,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Recent Colors",
|
||||
[],
|
||||
None,
|
||||
"{} → {colors: [{hex, color}] newest first (fed by every paint command and the eyedropper), lastColor: {hex, color}, lastGradient: gradient paint}",
|
||||
"{} → {colors: [{hex, color}] newest first, at most 30, each once, kept across documents for the session (fed by every paint command and the eyedropper), lastColor: {hex, color}, lastGradient: gradient paint}",
|
||||
always,
|
||||
|s, _| {
|
||||
let c = |c: &Color| json!({"hex": c.to_hex(), "color": c});
|
||||
@@ -152,8 +152,9 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// How many colours the Recent Colors rows keep.
|
||||
pub const RECENT_MAX: usize = 10;
|
||||
/// How many colors Recent Colors keeps. The Session holds the list, so it stays the same when
|
||||
/// another document is opened or made active.
|
||||
pub const RECENT_MAX: usize = 30;
|
||||
|
||||
/// Remember an applied paint: a solid colour becomes the last colour and the newest recent
|
||||
/// colour, a gradient the last gradient. During a live preview this waits for the commit.
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun};
|
||||
use vectorcraft_geom::{Rect, Vec2};
|
||||
|
||||
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices};
|
||||
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices, switch_or};
|
||||
use crate::{Clipboard, EngineError};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art` (locked and hidden art only with prefs moveLockedWithArtboard), in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}",
|
||||
"{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (locked and hidden art goes too; default: prefs moveLockedWithArtboard)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art`, in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}",
|
||||
has_doc,
|
||||
artboard_duplicate
|
||||
),
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index? | indices?, selectedArtboards?, art?} (as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
"{index? | indices?, selectedArtboards?, art?, lockedAndHidden?} (defaults as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
boards_and_art_noted(s, p, "artboard.copy")?;
|
||||
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Cut Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index? | indices?, selectedArtboards?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless prefs moveLockedWithArtboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
"{index? | indices?, selectedArtboards?, art?, lockedAndHidden?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless lockedAndHidden); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
boards_and_art_noted(s, p, "artboard.cut")?;
|
||||
@@ -253,14 +253,24 @@ pub(crate) fn refuse_locked(d: &vectorcraft_doc::Document, indices: &[usize]) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Does locked and hidden art go with an artboard (`lockedAndHidden` param, else prefs
|
||||
/// moveLockedWithArtboard)? Noted in the journal when `art` goes along, so a replay takes the same
|
||||
/// art whatever the preference is then.
|
||||
pub(crate) fn locked_and_hidden(s: &mut Session, p: &Value, art: bool, cmd: &str) -> Result<bool> {
|
||||
let all = switch_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard, cmd)?;
|
||||
if art {
|
||||
s.note_journal("lockedAndHidden", json!(all));
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
|
||||
/// ones only with prefs moveLockedWithArtboard), in paint order. With `moving` (a move or a cut),
|
||||
/// the art also on another locked artboard stays unless prefs moveLockedWithArtboard
|
||||
/// ([`vectorcraft_doc::Document::art_moving_with`]); a copy takes it.
|
||||
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Result<Vec<NodeId>> {
|
||||
/// ones only when `all`), in paint order. With `moving` (a move or a cut), the art also on another
|
||||
/// locked artboard stays unless `all` ([`vectorcraft_doc::Document::art_moving_with`]); a copy
|
||||
/// takes it.
|
||||
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, all: bool, moving: bool) -> Result<Vec<NodeId>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let all = s.prefs.move_locked_with_artboard;
|
||||
Ok(match (art, moving) {
|
||||
(false, _) => vec![],
|
||||
(true, false) => d.art_on_artboard(rect, all),
|
||||
@@ -270,11 +280,11 @@ pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Re
|
||||
|
||||
/// Each of `indices` with the art that goes with it when `art` ([`artboard_art`]), an object
|
||||
/// counted once, with the first of them it is on (artboards can overlap).
|
||||
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool, moving: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
|
||||
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool, all: bool, moving: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut arts = vec![];
|
||||
for &i in indices {
|
||||
let art = artboard_art(s, i, art, moving)?.into_iter().filter(|id| seen.insert(*id)).collect();
|
||||
let art = artboard_art(s, i, art, all, moving)?.into_iter().filter(|id| seen.insert(*id)).collect();
|
||||
arts.push((i, art));
|
||||
}
|
||||
Ok(arts)
|
||||
@@ -291,7 +301,8 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
|
||||
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &["patterns"], "artboard.duplicate")?;
|
||||
let (indices, art) = boards_and_art_noted(s, p, "artboard.duplicate")?;
|
||||
let arts = artboards_art(s, &indices, art, false)?;
|
||||
let locked_art = locked_and_hidden(s, p, art, "artboard.duplicate")?;
|
||||
let arts = artboards_art(s, &indices, art, locked_art, false)?;
|
||||
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
|
||||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() };
|
||||
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
|
||||
@@ -316,8 +327,10 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Artboard Copy / Cut: the artboards and their art onto the clipboard (pasting brings them all);
|
||||
/// a cut then deletes them.
|
||||
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
|
||||
let (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
|
||||
let arts = artboards_art(s, &indices, art, cut)?;
|
||||
let cmd = if cut { "artboard.cut" } else { "artboard.copy" };
|
||||
let (indices, art) = boards_and_art(s, p, cmd)?;
|
||||
let locked_art = locked_and_hidden(s, p, art, cmd)?;
|
||||
let arts = artboards_art(s, &indices, art, locked_art, cut)?;
|
||||
let st = s.doc()?;
|
||||
if cut && st.doc.artboards.len() <= indices.len() {
|
||||
return Err(EngineError::Other("a document needs at least one artboard".into()));
|
||||
|
||||
@@ -8,15 +8,19 @@
|
||||
//! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't
|
||||
//! described key by key, so only the top level is checked.
|
||||
//!
|
||||
//! Callers outside the app (the control channel, MCP, the CLI) are answered with a parameter error
|
||||
//! for a key the description doesn't name ([`check_params`]): a misspelt `widht` no longer runs the
|
||||
//! command with its default. So are the `params` of an effect (#1592): `effect.apply` and
|
||||
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
|
||||
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
|
||||
//! commands a command runs, the steps of a `command.batch` or an `action.batch` included: a
|
||||
//! misspelled `widht` no longer runs the command with its default. So are the `params` of an
|
||||
//! effect (#1592): `effect.apply` and
|
||||
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
|
||||
//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes
|
||||
//! what it is given, so the journal of an older version still replays, except for an effect's
|
||||
//! `color` that the effect can't read and a Levels or Curves `channel` that the effect doesn't
|
||||
//! have, which `effect.apply` and `effect.setParams` refuse themselves ([`check_effect_color`],
|
||||
//! [`check_effect_channel`]).
|
||||
//! default, with a parameter error ([`check_effect_params`]).
|
||||
//!
|
||||
//! Saved steps play through [`Session::replay`] instead: a journal or an action recorded by an older
|
||||
//! version, with a key a command no longer reads or an effect parameter it no longer takes, still
|
||||
//! plays. Even there, an effect's `color` that the effect can't read and a Levels or Curves
|
||||
//! `channel` that the effect doesn't have are refused by `effect.apply` and `effect.setParams`
|
||||
//! themselves ([`check_effect_color`], [`check_effect_channel`]).
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -473,10 +477,16 @@ pub const UNCHECKED: &[&str] = &[
|
||||
"webExport.settings",
|
||||
];
|
||||
|
||||
/// [`check_keys`] for an engine command, the steps of a `command.batch` included, and
|
||||
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included, and
|
||||
/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have
|
||||
/// passes: running it reports that.
|
||||
pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
check_params_in(id, params, false)
|
||||
}
|
||||
|
||||
/// [`check_params`] for a step of a batch when `in_batch`: batches can't nest, so the steps of a
|
||||
/// `command.batch` or `action.batch` step aren't checked (running it reports that), however deep they go.
|
||||
fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
|
||||
let Some(spec) = find_command(id) else { return Ok(()) };
|
||||
if let ("effect.apply" | "appearance.addEffect", Some(effect), Some(given)) =
|
||||
(id, params.get("effect").or_else(|| params.get("id")).and_then(Value::as_str), params.get("params"))
|
||||
@@ -487,10 +497,10 @@ pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
check_keys(id, spec.params, params)?;
|
||||
if id == "command.batch" {
|
||||
if matches!(id, "command.batch" | "action.batch") && !in_batch {
|
||||
for step in params.get("commands").and_then(Value::as_array).into_iter().flatten() {
|
||||
if let (Some(step_id), Some(step_params)) = (step.get("command").and_then(Value::as_str), step.get("params")) {
|
||||
check_params(step_id, step_params)?;
|
||||
check_params_in(step_id, step_params, true)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Outline Stroke",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{} replace stroked paths with filled outlines → {ids}",
|
||||
"{} replace stroked paths with filled outlines; a dimension whose lines are outlined, or selected with the art it measures when that art is outlined, becomes a plain group of its art → {ids}",
|
||||
has_selection,
|
||||
outline_stroke
|
||||
),
|
||||
@@ -980,6 +980,8 @@ pub(crate) fn replace_leaves(d: &mut Document, root: NodeId, mut f: impl FnMut(&
|
||||
fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let ids = s.edit("Outline Stroke", |d, sel| {
|
||||
let before = d.clone();
|
||||
let mut ended = super::dimension::end_links(d, &roots);
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let mut new_sel = vec![];
|
||||
let mut changed = 0;
|
||||
@@ -988,6 +990,9 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
new_sel.push(id);
|
||||
changed += n;
|
||||
}
|
||||
// A dimension whose source was outlined stays plain: its link no longer fits that art.
|
||||
ended.retain(|e| e.source_kept(&before, d));
|
||||
super::dimension::restore_unchanged(d, ended);
|
||||
if changed == 0 {
|
||||
return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into()));
|
||||
}
|
||||
|
||||
@@ -377,20 +377,24 @@ pub(crate) fn pattern_done(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if editing(s).is_some() {
|
||||
s.end_text_edit();
|
||||
}
|
||||
let name = s.edit("Done Editing Pattern", |d, sel| {
|
||||
let (pe, art) = leave_edit(d, sel)?;
|
||||
if let Some(def) = d.pattern_mut(&pe.pattern) {
|
||||
def.art = art;
|
||||
if def.size_tile_to_art {
|
||||
def.fit_tile_to_art();
|
||||
}
|
||||
}
|
||||
Ok(pe.pattern)
|
||||
})?;
|
||||
let name = s.edit("Done Editing Pattern", finish_edit)?;
|
||||
after_edit_mode(s, None)?;
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
/// Leave pattern editing mode in `d`, storing the editing layer's art as the pattern's tile art
|
||||
/// (resizing the tile to the art when the pattern does so) → the pattern's name.
|
||||
pub(crate) fn finish_edit(d: &mut Document, sel: &mut Selection) -> Result<String> {
|
||||
let (pe, art) = leave_edit(d, sel)?;
|
||||
if let Some(def) = d.pattern_mut(&pe.pattern) {
|
||||
def.art = art;
|
||||
if def.size_tile_to_art {
|
||||
def.fit_tile_to_art();
|
||||
}
|
||||
}
|
||||
Ok(pe.pattern)
|
||||
}
|
||||
|
||||
fn pattern_cancel(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.edit("Cancel Pattern Editing", |d, sel| {
|
||||
let (pe, _) = leave_edit(d, sel)?;
|
||||
|
||||
@@ -47,7 +47,7 @@ use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
use vectorcraft_pdf::CropTo;
|
||||
|
||||
use super::fileio::{self, Format, RasterImage};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, id_param, str_param, unique_name};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, object_param, str_param, unique_name};
|
||||
use crate::{EngineError, MAX_COORD};
|
||||
|
||||
const PLACE: &str = "file.place";
|
||||
@@ -866,7 +866,7 @@ pub fn read_info(p: &Value) -> Result<Value> {
|
||||
pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "image.info";
|
||||
let st = s.doc()?;
|
||||
let id = match (id_param(p, "id"), &st.selection.objects[..]) {
|
||||
let id = match (object_param(p, "id", C)?, &st.selection.objects[..]) {
|
||||
(Some(id), _) => id,
|
||||
(None, [id]) => *id,
|
||||
_ => return Err(bad(C, "select one image or pass id")),
|
||||
|
||||
+109
-25
@@ -13,6 +13,18 @@
|
||||
//! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`),
|
||||
//! which loading a folder doesn't ask, so folders skip it; the app runs it in the background with
|
||||
//! Cancel ([`FilterRun`]).
|
||||
//!
|
||||
//! A plug-in whose manifest lists permissions installs once the user grants them
|
||||
//! (`plugin.install {grant}`, recorded with the module's hash and kept in the plug-in store), and
|
||||
//! a filter does only what they allow: without `doc.read` it is given no objects, without
|
||||
//! `doc.write` an output that changes the document is refused, and without `state` it neither
|
||||
//! reads nor writes the data it keeps with the document.
|
||||
//!
|
||||
//! A plug-in of API level 2 that lists `progress` may import the host's `vc_progress`,
|
||||
//! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a
|
||||
//! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its
|
||||
//! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log
|
||||
//! under its id.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -22,6 +34,8 @@ use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registr
|
||||
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or};
|
||||
|
||||
use vectorcraft_plugins::permissions::{self, Grant, Permission};
|
||||
|
||||
/// What a plug-in error looks like to the user. A run the caller cancelled changed nothing.
|
||||
fn err(e: vectorcraft_plugins::Error) -> EngineError {
|
||||
match e {
|
||||
@@ -37,6 +51,7 @@ pub fn describe(p: &Plugin) -> Value {
|
||||
"id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description,
|
||||
"params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
|
||||
"size": p.size(), "source": p.source(),
|
||||
"permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api,
|
||||
});
|
||||
match m.kind {
|
||||
Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)),
|
||||
@@ -122,15 +137,17 @@ impl FilterRun {
|
||||
}
|
||||
// Bad parameters fail before anything runs.
|
||||
let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?);
|
||||
let may = m.permissions();
|
||||
let st = s.doc()?;
|
||||
let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone());
|
||||
let inputs = objects::filter_targets(&st.doc, &selected);
|
||||
// Without `doc.read` a filter is given no objects: it can only add new ones.
|
||||
let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() };
|
||||
let parent = st.insertion_parent();
|
||||
let bounds = st.doc.bounds_of(&inputs, false);
|
||||
let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first());
|
||||
let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
|
||||
// What the plug-in keeps with the document, as it last wrote it.
|
||||
let data = st.doc.plugin_data(&m.id).cloned().unwrap_or(Value::Null);
|
||||
let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null);
|
||||
params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data});
|
||||
let input = objects::encode_input(&st.doc, &inputs);
|
||||
let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?;
|
||||
@@ -175,13 +192,23 @@ impl FilterRun {
|
||||
}
|
||||
|
||||
/// Applies the plug-in's `output` to the active document as one undo step: its objects, and what
|
||||
/// it keeps with the document from now on.
|
||||
/// it keeps with the document from now on. An output its permissions don't allow is refused,
|
||||
/// and the document stays as it was.
|
||||
fn finish(self, s: &mut Session, output: &[u8]) -> Result<Value> {
|
||||
let m = self.plugin.manifest();
|
||||
let may = m.permissions();
|
||||
let out = objects::decode_output(output).map_err(err)?;
|
||||
if out.document_data.is_some() && !may.contains(&Permission::State) {
|
||||
return Err(not_granted(&m.name, Permission::State));
|
||||
}
|
||||
let may_write = may.contains(&Permission::DocWrite);
|
||||
let (inputs, parent) = (self.inputs, self.parent);
|
||||
let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| {
|
||||
let before = (!may_write).then(|| d.layers.clone());
|
||||
let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?;
|
||||
if before.is_some_and(|b| b != d.layers) {
|
||||
return Err(not_granted(&m.name, Permission::DocWrite));
|
||||
}
|
||||
if let Some(data) = out.document_data {
|
||||
d.set_plugin_data(&m.id, data);
|
||||
}
|
||||
@@ -192,6 +219,12 @@ impl FilterRun {
|
||||
}
|
||||
}
|
||||
|
||||
/// The error of a filter `name` whose output needs `permission`, which it wasn't granted.
|
||||
fn not_granted(name: &str, permission: Permission) -> EngineError {
|
||||
let (name, p) = (permissions::display_text(name, permissions::MAX_SHOWN_NAME), permission.name());
|
||||
err(vectorcraft_plugins::Error::Output(format!("“{name}” wasn't granted `{p}`, which its output needs: nothing was changed")))
|
||||
}
|
||||
|
||||
/// `plugin.run`: runs filter `id` on `ids` (else the selected paths and compound paths) with its
|
||||
/// parameters and `_context` (what it keeps with the document included), and applies its output as
|
||||
/// one undo step ([`objects::apply_output`]; effect records as [`admit_effects`] admits them), with
|
||||
@@ -283,13 +316,37 @@ fn load_given(c: &str, p: &Value) -> Result<(Plugin, Vec<u8>)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The permissions `grant` lists in command `c`'s params: `None` when it is left out (or null).
|
||||
/// Anything but a list of permission names is an error.
|
||||
fn grant_param(c: &str, p: &Value) -> Result<Option<Vec<Permission>>> {
|
||||
let Some(v) = p.get("grant").filter(|v| !v.is_null()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let names = v.as_array().ok_or_else(|| bad(c, "`grant` must be a list of permission names"))?;
|
||||
let mut out = Vec::with_capacity(names.len());
|
||||
for n in names {
|
||||
let name = n.as_str().ok_or_else(|| bad(c, "`grant` must be a list of permission names"))?;
|
||||
let shown = permissions::display_text(name, permissions::MAX_SHOWN_FIELD);
|
||||
out.push(Permission::parse(name).ok_or_else(|| bad(c, format!("`grant` names an unknown permission `{shown}`")))?);
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless
|
||||
/// `allowHeavy` says the user agreed, keeps it for the next start and installs it → its
|
||||
/// `plugin.list` entry.
|
||||
/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted its id
|
||||
/// unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records the grant
|
||||
/// with the module's hash, keeps it for the next start and installs it → its `plugin.list` entry.
|
||||
fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.install";
|
||||
let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?);
|
||||
let grant = grant_param(C, p)?;
|
||||
let (plugin, bytes) = load_given(C, p)?;
|
||||
// `hash` pins the module the user was shown: one with other bytes is refused.
|
||||
if let Some(h) = p.get("hash").filter(|v| !v.is_null())
|
||||
&& h.as_str() != Some(plugin.hash())
|
||||
{
|
||||
return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed"));
|
||||
}
|
||||
if !replace && registry::get(plugin.id()).is_some() {
|
||||
return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())));
|
||||
}
|
||||
@@ -304,14 +361,33 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
),
|
||||
));
|
||||
}
|
||||
// A plug-in that lists permissions installs once the user grants them: whoever installs it
|
||||
// asks them first, unless they already granted its id all of them.
|
||||
let m = plugin.manifest();
|
||||
let wanted = m.permissions();
|
||||
if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) {
|
||||
let name = permissions::display_text(&m.name, permissions::MAX_SHOWN_NAME);
|
||||
let asked = permissions::names(&wanted).join(", ");
|
||||
return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees")));
|
||||
}
|
||||
if m.asks_permissions() {
|
||||
let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted };
|
||||
registry::grant(g.clone());
|
||||
s.store_grant(&m.id, Some(g));
|
||||
}
|
||||
// Installed plug-ins stay installed after a restart (#2541).
|
||||
s.store_plugin(plugin.id(), &bytes);
|
||||
Ok(describe(®istry::install(plugin)))
|
||||
}
|
||||
|
||||
/// `plugin.inspect`: what installing the module would install, without installing it.
|
||||
fn inspect(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
load_given("plugin.inspect", p).map(|(plugin, _)| describe(&plugin))
|
||||
/// `plugin.inspect`: what installing the module would install, without installing it, and whether
|
||||
/// that needs the user to grant its permissions first (`needsApproval`).
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
load_given("plugin.inspect", p).map(|(plugin, _)| {
|
||||
let mut v = describe(&plugin);
|
||||
v["needsApproval"] = json!(!s.plugin_approved(&plugin));
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
/// The plug-in in the `.wasm` file `path`, and its module.
|
||||
@@ -328,35 +404,40 @@ fn load_path(_: &str) -> Result<(Plugin, Vec<u8>)> {
|
||||
Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into()))
|
||||
}
|
||||
|
||||
/// The plug-in folder last loaded from the preferences, and what happened.
|
||||
static FOLDER: Mutex<Option<(String, Value)>> = Mutex::new(None);
|
||||
/// The plug-in folder last loaded, the plug-in store whose grants it was loaded with, and what
|
||||
/// happened.
|
||||
static FOLDER: Mutex<Option<(String, Option<std::path::PathBuf>, Value)>> = Mutex::new(None);
|
||||
|
||||
/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and has changed since
|
||||
/// the last load. Runs whenever preferences are applied (startup, `prefs.set`). Native only.
|
||||
/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and it, or the
|
||||
/// session's plug-in store, has changed since the last load. Runs whenever preferences are applied
|
||||
/// (startup, `prefs.set`). Native only.
|
||||
pub(crate) fn sync_prefs(s: &Session) {
|
||||
let folder = s.prefs.plugins_folder.trim();
|
||||
if folder.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.as_ref().is_some_and(|(f, _)| f == folder) {
|
||||
if last.as_ref().is_some_and(|(f, store, _)| f == folder && *store == s.plugin_dir) {
|
||||
return;
|
||||
}
|
||||
*last = Some((folder.to_string(), load_folder(folder)));
|
||||
*last = Some((folder.to_string(), s.plugin_dir.clone(), load_folder(s, folder)));
|
||||
}
|
||||
|
||||
/// Loads the plug-ins in `folder`, those that list permissions only when `s` has them granted
|
||||
/// ([`Session::plugin_approved`], with the store's grants read once).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_folder(folder: &str) -> Value {
|
||||
fn load_folder(s: &Session, folder: &str) -> Value {
|
||||
if let Err(e) = crate::file_access::check_read(folder) {
|
||||
return json!({"folder": folder, "error": e});
|
||||
}
|
||||
// A file in it may be a link to one elsewhere.
|
||||
match registry::load_folder_where(std::path::Path::new(folder), |f| match f.to_str() {
|
||||
let allow = |f: &std::path::Path| match f.to_str() {
|
||||
Some(f) => crate::file_access::check_read(f),
|
||||
// A lossy name would check another file than the one loaded.
|
||||
None if crate::file_access::current().is_none() => Ok(()),
|
||||
None => Err(format!("automation path rejected: {} isn't valid text", f.display())),
|
||||
}) {
|
||||
};
|
||||
match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) {
|
||||
Ok(r) => {
|
||||
json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::<Vec<_>>()})
|
||||
}
|
||||
@@ -365,7 +446,7 @@ fn load_folder(folder: &str) -> Value {
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_folder(folder: &str) -> Value {
|
||||
fn load_folder(_: &Session, folder: &str) -> Value {
|
||||
json!({"folder": folder, "error": "plug-in folders are not available on the web"})
|
||||
}
|
||||
|
||||
@@ -378,16 +459,16 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if folder.is_empty() {
|
||||
return Err(bad(C, "no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder"));
|
||||
}
|
||||
let report = load_folder(&folder);
|
||||
let report = load_folder(s, &folder);
|
||||
if let Some(e) = report.get("error").and_then(Value::as_str) {
|
||||
return Err(EngineError::Other(e.to_string()));
|
||||
}
|
||||
*FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, report.clone()));
|
||||
*FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, s.plugin_dir.clone(), report.clone()));
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, r)| r.clone());
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone());
|
||||
Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::<Vec<_>>(), "folder": folder}))
|
||||
}
|
||||
|
||||
@@ -402,6 +483,9 @@ fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?;
|
||||
// Installed again, it asks again.
|
||||
registry::revoke(id);
|
||||
s.store_grant(id, None);
|
||||
s.unstore_plugin(id);
|
||||
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) }
|
||||
}
|
||||
@@ -415,7 +499,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"List Plug-ins",
|
||||
[],
|
||||
None,
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -442,7 +526,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Install Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
always,
|
||||
install
|
||||
),
|
||||
@@ -451,7 +535,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Inspect Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant)",
|
||||
always,
|
||||
inspect
|
||||
),
|
||||
@@ -461,7 +545,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reload Plug-ins",
|
||||
["Object", "Plug-ins"],
|
||||
None,
|
||||
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped) → {folder, loaded: [id], failed: [{file, error}]}",
|
||||
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id) → {folder, loaded: [id], failed: [{file, error}]}",
|
||||
always,
|
||||
reload
|
||||
),
|
||||
|
||||
@@ -152,6 +152,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool),
|
||||
p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool),
|
||||
p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool),
|
||||
p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool),
|
||||
// Selection & Anchor Display
|
||||
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
|
||||
p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool),
|
||||
@@ -247,6 +248,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("measurementLabels", "Smart Guides", "Display Options", "Measurement Labels", bool),
|
||||
p!("spacingGuides", "Smart Guides", "Display Options", "Spacing Guides", bool),
|
||||
p!("distanceGuides", "Smart Guides", "Display Options", "Invoke Distance Guides", bool),
|
||||
p!("snapToLastLocation", "Smart Guides", "Display Options", "Snap to Last Location", bool),
|
||||
p!("snappingTolerance", "Smart Guides", "", "Snapping Tolerance", num(0.0, 40.0, "pt")),
|
||||
p!("snapTangent", "Smart Guides", "Geometric Guides", "Snap to Tangent", bool),
|
||||
p!("snapPerpendicular", "Smart Guides", "Geometric Guides", "Snap to Perpendicular", bool),
|
||||
@@ -422,6 +424,9 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
// The Align panel menu's Align to Glyph Bounds: Align and Distribute measure type by its glyphs' ink.
|
||||
p!("alignGlyphBoundsPoint", "Type", "Align to Glyph Bounds", "Point Text", bool),
|
||||
p!("alignGlyphBoundsArea", "Type", "Align to Glyph Bounds", "Area Text", bool),
|
||||
// Artboard Options › Global: the canvas darkens the area outside the artboards while the Artboard tool is active.
|
||||
p!("fadeRegionOutsideArtboard", "General", "Artboard Options", "Fade Region Outside Artboard", bool),
|
||||
p!("artboardUpdateWhileDragging", "General", "Artboard Options", "Update While Dragging", bool),
|
||||
];
|
||||
|
||||
pub fn spec(key: &str) -> Option<&'static PrefSpec> {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! art retained underneath. Recipes select topology regions; source edits re-evaluate them in
|
||||
//! the same undo transaction. Rendering and exporters only see ordinary groups and paths.
|
||||
use super::pathops::Beside;
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, point_param};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, object_param, point_param};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
@@ -785,15 +785,15 @@ fn end_isolation(s: &mut Session) -> Result<()> {
|
||||
st.isolation = editing_layer(&st.doc);
|
||||
Ok(())
|
||||
}
|
||||
fn target(s: &Session, p: &Value) -> Result<NodeId> {
|
||||
let id = id_param(p, "id")
|
||||
fn target(s: &Session, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
let id = object_param(p, "id", cmd)?
|
||||
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
|
||||
.ok_or_else(|| bad("shaper.release", "select a Shaper group"))?;
|
||||
s.doc()?.doc.node(id).filter(|n| n.extras().shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?;
|
||||
.ok_or_else(|| bad(cmd, "select a Shaper group"))?;
|
||||
s.doc()?.doc.node(id).filter(|n| n.extras().shaper.is_some()).ok_or_else(|| bad(cmd, "select a Shaper group"))?;
|
||||
Ok(id)
|
||||
}
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p)?;
|
||||
let id = target(s, p, "shaper.release")?;
|
||||
let ids = s.edit("Release Shaper Group", |d, sel| {
|
||||
let original = d.node(id).map(sources).ok_or(EngineError::NoNode(id))?;
|
||||
let (parent, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
@@ -809,7 +809,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p)?;
|
||||
let id = target(s, p, "shaper.expand")?;
|
||||
s.edit("Expand Shaper Group", |d, sel| {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
n.extras_mut().shaper = None;
|
||||
|
||||
@@ -20,7 +20,7 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, index, ok, str_param, unique_name};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, object_param, ok, str_param, unique_name};
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -290,7 +290,7 @@ fn style_index(d: &Document, name: &str) -> Result<usize> {
|
||||
|
||||
/// The object a style is made from: `id`, else the first selected object.
|
||||
fn source<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(NodeId, &'a Node)> {
|
||||
let id = match id_param(p, "id") {
|
||||
let id = match object_param(p, "id", cmd)? {
|
||||
Some(id) => id,
|
||||
None => super::edit::selected_roots(s)?.first().copied().ok_or_else(|| bad(cmd, "select an object (or pass `id`)"))?,
|
||||
};
|
||||
|
||||
+122
-27
@@ -1,16 +1,15 @@
|
||||
//! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup, tint_percent};
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientStop, Paint, Swatch, SwatchGroup, tint_percent};
|
||||
use vectorcraft_doc::pattern::pattern_paint;
|
||||
use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors};
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
|
||||
use super::paint::paint_from;
|
||||
use super::{
|
||||
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, index, ok, str_param, switch_or,
|
||||
unique_name,
|
||||
};
|
||||
use super::{CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, index, ok, str_param, switch_or};
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -195,10 +194,11 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let requested = name_param(p, "name").filter(|_| paints.len() == 1);
|
||||
let names = s.edit("New Swatch", |d, _| {
|
||||
let mut names = vec![];
|
||||
let mut free = d.free_swatch_names();
|
||||
for paint in paints {
|
||||
let name = match &requested {
|
||||
Some(n) => d.free_swatch_name(n),
|
||||
None => d.new_swatch_name(&paint),
|
||||
Some(n) => free.take(n),
|
||||
None => free.take_new(&paint),
|
||||
};
|
||||
// A tint swatch's kind is its base's.
|
||||
let tint = matches!(paint, Paint::Solid { swatch: Some(_), .. });
|
||||
@@ -271,36 +271,116 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// each). A tint (a linked colour that differs from its swatch's) brings its swatch, and with
|
||||
/// `tints` its own colour too. Links to missing swatches count as unlinked.
|
||||
fn artwork_colors(d: &Document, ids: &[NodeId], tints: bool, swatches: &mut Vec<String>, colors: &mut Vec<Color>) {
|
||||
let by_name = swatches_by_name(d);
|
||||
let mut seen_swatches: HashSet<String> = swatches.iter().cloned().collect();
|
||||
let mut seen_colors: HashSet<ColorKey> = colors.iter().filter_map(color_key).collect();
|
||||
for n in ids.iter().filter_map(|id| d.node(*id)) {
|
||||
node_colors(n, &mut |c, link| {
|
||||
let sw = link.and_then(|l| d.swatch(l)).filter(|w| w.paint.color().is_some());
|
||||
let sw = link.and_then(|l| by_name.get(l).copied()).filter(|w| w.paint.color().is_some());
|
||||
if let Some(w) = sw
|
||||
&& !swatches.contains(&w.name)
|
||||
&& !seen_swatches.contains(&w.name)
|
||||
{
|
||||
seen_swatches.insert(w.name.clone());
|
||||
swatches.push(w.name.clone());
|
||||
}
|
||||
let own = sw.is_none_or(|w| tints && w.paint.color() != Some(*c));
|
||||
if own && !colors.contains(c) {
|
||||
// A color with a NaN component equals no color, so it is never already listed.
|
||||
if own && color_key(c).is_none_or(|k| seen_colors.insert(k)) {
|
||||
colors.push(*c);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Link the solid paints in the subtrees of `ids` whose colour is one of `links`' to that swatch,
|
||||
/// unless they already link to an existing swatch. Returns the number of paints linked.
|
||||
/// Every swatch by name, as [`Document::swatch`] finds it: the first swatch of each name, and the
|
||||
/// built-in Registration swatch when no swatch has its name.
|
||||
fn swatches_by_name(d: &Document) -> HashMap<&str, &Swatch> {
|
||||
let mut by_name = HashMap::new();
|
||||
for w in d.swatches_iter().chain([vectorcraft_color::swatch::registration()]) {
|
||||
by_name.entry(w.name.as_str()).or_insert(w);
|
||||
}
|
||||
by_name
|
||||
}
|
||||
|
||||
/// A color's model and the bits of its components, with -0.0 read as 0.0: two keys are equal
|
||||
/// exactly when the colors are equal (`==`). `None` for a color with a NaN component, which
|
||||
/// equals no color.
|
||||
type ColorKey = (u8, [u32; 4]);
|
||||
|
||||
fn color_key(c: &Color) -> Option<ColorKey> {
|
||||
let (model, v) = match *c {
|
||||
Color::Rgb { r, g, b } => (0, [r, g, b, 0.0]),
|
||||
Color::Cmyk { c, m, y, k } => (1, [c, m, y, k]),
|
||||
Color::Gray { k } => (2, [k, 0.0, 0.0, 0.0]),
|
||||
Color::Lab { l, a, b } => (3, [l, a, b, 0.0]),
|
||||
};
|
||||
Some((model, [float_key(v[0])?, float_key(v[1])?, float_key(v[2])?, float_key(v[3])?]))
|
||||
}
|
||||
|
||||
/// The bits of `v`, with -0.0 read as 0.0, so keys are equal exactly when the values are; `None`
|
||||
/// for NaN.
|
||||
fn float_key(v: f32) -> Option<u32> {
|
||||
if v.is_nan() {
|
||||
None
|
||||
} else if v == 0.0 {
|
||||
Some(0)
|
||||
} else {
|
||||
Some(v.to_bits())
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient stop's offset, color, opacity, midpoint, swatch link, tint and process plates, as
|
||||
/// keys.
|
||||
type StopKey<'a> = (u32, ColorKey, u32, u32, Option<&'a str>, u32, Option<[u32; 4]>);
|
||||
|
||||
/// A gradient's kind, method, dither and stops, as keys.
|
||||
type GradientKey<'a> = (u8, u8, bool, Vec<StopKey<'a>>);
|
||||
|
||||
/// A key that is equal for two gradients exactly when the gradients are equal (`==`); `None` when
|
||||
/// any of its numbers is NaN, so the gradient equals no gradient.
|
||||
fn gradient_key(g: &Gradient) -> Option<GradientKey<'_>> {
|
||||
let kind = match g.kind {
|
||||
GradientKind::Linear => 0,
|
||||
GradientKind::Radial => 1,
|
||||
GradientKind::Freeform => 2,
|
||||
};
|
||||
let method = match g.method {
|
||||
GradientMethod::Classic => 0,
|
||||
GradientMethod::Perceptual => 1,
|
||||
};
|
||||
Some((kind, method, g.dither, g.stops.iter().map(stop_key).collect::<Option<_>>()?))
|
||||
}
|
||||
|
||||
/// A stop's key for [`gradient_key`]; `None` when any of its numbers is NaN.
|
||||
fn stop_key(s: &GradientStop) -> Option<StopKey<'_>> {
|
||||
let plates = match s.plates {
|
||||
Some(p) => Some([float_key(p[0])?, float_key(p[1])?, float_key(p[2])?, float_key(p[3])?]),
|
||||
None => None,
|
||||
};
|
||||
Some((float_key(s.offset)?, color_key(&s.color)?, float_key(s.opacity)?, float_key(s.midpoint)?, s.swatch.as_deref(), float_key(s.tint)?, plates))
|
||||
}
|
||||
|
||||
/// Link the solid paints in the subtrees of `ids` whose color is one of `links`' to that swatch
|
||||
/// (the first one listed for the color), unless they already link to an existing swatch or to the
|
||||
/// built-in Registration swatch. Returns the number of paints linked.
|
||||
fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> usize {
|
||||
if links.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let live: Vec<String> = d.swatches_iter().map(|w| w.name.clone()).collect();
|
||||
let live: HashSet<String> = d.swatches_iter().chain([vectorcraft_color::swatch::registration()]).map(|w| w.name.clone()).collect();
|
||||
let mut by_color: HashMap<ColorKey, &String> = HashMap::new();
|
||||
for (c, n) in links {
|
||||
if let Some(k) = color_key(c) {
|
||||
by_color.entry(k).or_insert(n);
|
||||
}
|
||||
}
|
||||
d.map_solid_paints_in(ids, &mut |c, link, _| {
|
||||
if link.as_ref().is_some_and(|l| live.contains(l)) {
|
||||
return false;
|
||||
}
|
||||
match links.iter().find(|(lc, _)| lc == c) {
|
||||
Some((_, n)) => {
|
||||
*link = Some(n.clone());
|
||||
match color_key(c).and_then(|k| by_color.get(&k)) {
|
||||
Some(n) => {
|
||||
*link = Some((*n).clone());
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
@@ -346,8 +426,11 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// The global swatches made from the art's colours, which its unlinked paints link to.
|
||||
let mut links: Vec<(Color, String)> = vec![];
|
||||
let mut free = d.free_swatch_names();
|
||||
free.insert(&name);
|
||||
group.swatches.iter().for_each(|sw| free.insert(&sw.name));
|
||||
for (c, global) in new {
|
||||
let nm = unique_name(&color_name(c), |n| n == name || d.swatch_name_taken(n) || group.swatches.iter().any(|sw| sw.name == n));
|
||||
let nm = free.take(&color_name(c));
|
||||
if global {
|
||||
links.push((c, nm.clone()));
|
||||
}
|
||||
@@ -798,10 +881,12 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mut linked_to, mut colors) = (vec![], vec![]);
|
||||
artwork_colors(d, &ids, false, &mut linked_to, &mut colors);
|
||||
// Colours a solid swatch already has are skipped.
|
||||
colors.retain(|c| !d.swatches_iter().any(|w| w.paint.color() == Some(*c)));
|
||||
let have: HashSet<ColorKey> = d.swatches_iter().filter_map(|w| w.paint.color()).filter_map(|c| color_key(&c)).collect();
|
||||
colors.retain(|c| color_key(c).is_none_or(|k| !have.contains(&k)));
|
||||
let mut free = d.free_swatch_names();
|
||||
let mut links = vec![];
|
||||
for c in colors {
|
||||
let name = d.free_swatch_name(&color_name(c));
|
||||
let name = free.take(&color_name(c));
|
||||
d.swatches.push(Swatch { name: name.clone(), paint: Paint::solid(c), global, spot: false });
|
||||
links.push((c, name));
|
||||
}
|
||||
@@ -813,10 +898,11 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn swatch_unused(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let by_name = swatches_by_name(d);
|
||||
// Links with their tints, so a tint swatch is used where its tint of its base is.
|
||||
let (mut links, mut colors, mut gradients, mut patterns, mut gradient_links) = (vec![], vec![], vec![], vec![], vec![]);
|
||||
let mut color = |c: &Color, link: Option<&String>, tint: f32| match link {
|
||||
Some(l) if d.swatch(l).is_some() => links.push((l.clone(), tint)),
|
||||
Some(l) if by_name.contains_key(l.as_str()) => links.push((l.clone(), tint)),
|
||||
_ => colors.push(*c),
|
||||
};
|
||||
let mut paint = |p: &Paint| match p {
|
||||
@@ -838,16 +924,22 @@ fn swatch_unused(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let gradient_links: HashSet<&String> = gradient_links.iter().collect();
|
||||
let link_names: HashSet<&str> = links.iter().map(|(l, _)| l.as_str()).collect();
|
||||
let link_tints: HashSet<(&str, u32)> = links.iter().filter_map(|(l, t)| Some((l.as_str(), float_key(*t)?))).collect();
|
||||
let colors: HashSet<ColorKey> = colors.iter().filter_map(color_key).collect();
|
||||
let patterns: HashSet<&String> = patterns.iter().collect();
|
||||
let gradients: HashSet<GradientKey> = gradients.iter().filter_map(gradient_key).collect();
|
||||
let used = |w: &Swatch| {
|
||||
gradient_links.contains(&w.name)
|
||||
|| links.iter().any(|(l, t)| match w.tint_of() {
|
||||
Some((base, tint)) => l == base && *t == tint,
|
||||
None => *l == w.name,
|
||||
})
|
||||
|| match w.tint_of() {
|
||||
Some((base, tint)) => float_key(tint).is_some_and(|t| link_tints.contains(&(base, t))),
|
||||
None => link_names.contains(w.name.as_str()),
|
||||
}
|
||||
|| match &w.paint {
|
||||
Paint::None => true,
|
||||
Paint::Solid { color, .. } => colors.contains(color),
|
||||
Paint::Gradient(g) => gradients.contains(&g.gradient),
|
||||
Paint::Solid { color, .. } => color_key(color).is_some_and(|k| colors.contains(&k)),
|
||||
Paint::Gradient(g) => gradient_key(&g.gradient).is_some_and(|k| gradients.contains(&k)),
|
||||
Paint::Pattern { pattern, .. } => patterns.contains(pattern),
|
||||
}
|
||||
};
|
||||
@@ -1017,8 +1109,11 @@ pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], ren
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut free = d.free_swatch_names();
|
||||
free.insert(&name);
|
||||
swatches.iter().for_each(|w| free.insert(&w.name));
|
||||
for c in colors.iter().skip(swatches.len()) {
|
||||
let nm = unique_name(&color_name(*c), |n| n == name || d.swatch_name_taken(n) || swatches.iter().any(|w| w.name == n));
|
||||
let nm = free.take(&color_name(*c));
|
||||
swatches.push(Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false });
|
||||
}
|
||||
let g = &mut d.swatch_groups[gi];
|
||||
|
||||
@@ -20,7 +20,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection, SymbolEdit, S
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::brushsym::{natural_size, place_xf};
|
||||
use super::{EngineError, Result, Session, bad, id_param, number_or, ok, str_param};
|
||||
use super::{EngineError, Result, Session, bad, number_or, object_param, ok, str_param};
|
||||
|
||||
/// The name of the undo step that enters symbol editing mode.
|
||||
const ENTER: &str = "Edit Symbol";
|
||||
@@ -65,7 +65,7 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.doc.pattern_edit.is_some() {
|
||||
return Err(bad(C, "finish editing the pattern first"));
|
||||
}
|
||||
let given = id_param(p, "id").map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty()));
|
||||
let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty()));
|
||||
let (name, xf, instance) = match given {
|
||||
Some(ids) => {
|
||||
let symbols = ids.iter().map(|id| instance_of(&st.doc, *id)).collect::<Option<Vec<_>>>();
|
||||
|
||||
@@ -1676,7 +1676,7 @@ pub(crate) fn type_ranges(
|
||||
at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>,
|
||||
) -> Result<Vec<(NodeId, usize, usize, bool)>> {
|
||||
// (text, its range; None: all of it).
|
||||
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match id_param(p, "id") {
|
||||
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match super::object_param(p, "id", c)? {
|
||||
// An object that isn't type is refused as `ids` of it is.
|
||||
Some(id) if s.doc()?.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Text(_))) => {
|
||||
return Err(bad(c, "no text objects selected"));
|
||||
|
||||
@@ -10,8 +10,9 @@ use serde::de::DeserializeOwned;
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TextStyleDef};
|
||||
|
||||
use super::clipboard::resources::import_text_styles;
|
||||
use super::typecmd::{refresh_bounds, text_targets};
|
||||
use super::{CommandSpec, Result, Run, Session, bad, bool_or, has_doc, id_param, index, ok, str_param};
|
||||
use super::{CommandSpec, Result, Run, Session, bad, bool_or, has_doc, index, object_param, ok, str_param};
|
||||
|
||||
pub const NORMAL_CHAR: &str = "[Normal Character Style]";
|
||||
pub const NORMAL_PARA: &str = "[Normal Paragraph Style]";
|
||||
@@ -111,10 +112,21 @@ macro_rules! style_cmds {
|
||||
|s, _| unused(s, $kind),
|
||||
false,
|
||||
),
|
||||
spec(
|
||||
concat!($p, ".load"),
|
||||
concat!("Load ", $what, " Styles…"),
|
||||
&["Window", "Type", $menu],
|
||||
LOAD_PARAMS,
|
||||
|s, p| load(s, p, &[$kind]),
|
||||
true,
|
||||
),
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`.
|
||||
const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = style_cmds!(
|
||||
Kind::Char,
|
||||
@@ -132,6 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
true,
|
||||
));
|
||||
v.extend(style_cmds!(Kind::Para, "paraStyle", "Paragraph Styles", "Paragraph", ""));
|
||||
v.push(spec("textStyle.loadAll", "Load All Styles…", &["Window", "Type"], LOAD_PARAMS, |s, p| load(s, p, &[Kind::Char, Kind::Para]), true));
|
||||
v
|
||||
}
|
||||
|
||||
@@ -426,10 +439,11 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
|
||||
|
||||
/// The selected text's attributes: the first selected character for a nonempty range,
|
||||
/// the insertion style at a lone `start`, or the first selected text object's first run.
|
||||
/// Paragraph capture always samples the paragraph at `start`.
|
||||
fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<String, Value>>> {
|
||||
/// Paragraph capture always samples the paragraph at `start`. An `id` that isn't an object id is
|
||||
/// an error under the `verb` command of `kind`.
|
||||
fn selection_attrs(s: &Session, p: &Value, kind: Kind, verb: &str) -> Result<Option<Map<String, Value>>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let id = match id_param(p, "id") {
|
||||
let id = match object_param(p, "id", &kind.cmd(verb))? {
|
||||
Some(id) => Some(id),
|
||||
None => text_targets(s, &json!({}), "").ok().and_then(|v| v.first().copied()),
|
||||
};
|
||||
@@ -451,14 +465,14 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
|
||||
/// The selected text's character (`para`: paragraph) attributes, every one of them: what a
|
||||
/// library keeps of a text style. `None` without text selected.
|
||||
pub(crate) fn selected_style_attrs(s: &Session, para: bool) -> Result<Option<Map<String, Value>>> {
|
||||
selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char })
|
||||
selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char }, "new")
|
||||
}
|
||||
|
||||
/// The attributes `charStyle.new` (`para`: `paraStyle.new`) would give a style made now from
|
||||
/// `target` (its `id`, `start` and `end`; empty for the selected objects). `None` without text
|
||||
/// there.
|
||||
pub fn target_style_attrs(s: &Session, target: &Value, para: bool) -> Option<Map<String, Value>> {
|
||||
selection_attrs(s, target, if para { Kind::Para } else { Kind::Char }).ok().flatten()
|
||||
selection_attrs(s, target, if para { Kind::Para } else { Kind::Char }, "new").ok().flatten()
|
||||
}
|
||||
|
||||
/// The attributes a new character or paragraph style receives when no text is selected.
|
||||
@@ -631,7 +645,7 @@ fn new(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
/// (`charStyle.duplicate`), which `attrs` keeps as [`attrs_param`] keeps the target text's.
|
||||
fn new_from(s: &mut Session, p: &Value, kind: Kind, copied: Option<&Map<String, Value>>) -> Result<Value> {
|
||||
let c = kind.cmd("new");
|
||||
let target = selection_attrs(s, p, kind)?;
|
||||
let target = selection_attrs(s, p, kind, "new")?;
|
||||
let attrs = match attrs_param(&s.doc()?.doc, p, kind, "new", copied.or(target.as_ref()))? {
|
||||
Some(a) => a,
|
||||
None => match target {
|
||||
@@ -672,8 +686,9 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
let style_name = (name != kind.normal()).then(|| name.clone());
|
||||
// A Type tool range or whole text objects.
|
||||
let text_range = super::typecmd::TextRange::parse(p, &c)?;
|
||||
let range = id_param(p, "id").filter(|_| text_range.is_some());
|
||||
let ids = match range.or_else(|| id_param(p, "id")) {
|
||||
let given = object_param(p, "id", &c)?;
|
||||
let range = given.filter(|_| text_range.is_some());
|
||||
let ids = match given {
|
||||
Some(id) => vec![id],
|
||||
None => text_targets(s, p, &c)?,
|
||||
};
|
||||
@@ -754,7 +769,7 @@ fn redefine_to(s: &mut Session, kind: Kind, name: &str, new_attrs: Map<String, V
|
||||
|
||||
fn redefine(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
let name = name_param(p, kind, "redefine")?.to_string();
|
||||
let attrs = selection_attrs(s, p, kind)?.ok_or_else(|| bad(&kind.cmd("redefine"), "select text to redefine the style from"))?;
|
||||
let attrs = selection_attrs(s, p, kind, "redefine")?.ok_or_else(|| bad(&kind.cmd("redefine"), "select text to redefine the style from"))?;
|
||||
redefine_to(s, kind, &name, attrs, "redefine")
|
||||
}
|
||||
|
||||
@@ -899,5 +914,89 @@ fn unused(s: &mut Session, kind: Kind) -> Result<Value> {
|
||||
Ok(json!({ "names": names }))
|
||||
}
|
||||
|
||||
/// The styles of `kind` in `src` that load, with their source names so that art in the file keeps
|
||||
/// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes,
|
||||
/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, or whose name
|
||||
/// is empty, which are skipped. → (the styles, the names in `src` of those skipped)
|
||||
fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
|
||||
const RANGED: [&str; 8] = ["size", "h_scale", "v_scale", "leading", "baseline_shift", "tcyUpDown", "tcyLeftRight", "stroke_width"];
|
||||
let (mut defs, mut skipped) = (vec![], vec![]);
|
||||
for def in kind.defs(src) {
|
||||
let name = def.name.as_str();
|
||||
if name.trim() == kind.normal() {
|
||||
continue;
|
||||
}
|
||||
// Paints, features and language are taken as the file stores them; the ranged values are checked.
|
||||
let mut kept = def.attrs.clone();
|
||||
kept.retain(|k, _| !RANGED.contains(&k.as_str()));
|
||||
match attrs_param(src, &json!({ "attrs": def.attrs }), kind, "load", Some(&kept)).ok().flatten().filter(|_| !name.trim().is_empty()) {
|
||||
Some(attrs) => defs.push(TextStyleDef { name: name.to_string(), attrs }),
|
||||
None => skipped.push(def.name.clone()),
|
||||
}
|
||||
}
|
||||
(defs, skipped)
|
||||
}
|
||||
|
||||
/// Load Character Styles, Load Paragraph Styles and Load All Styles: the `kinds` of style of the
|
||||
/// document `p` gives (`path`, `data` or `dataBase64`) added to the active one with the swatches and
|
||||
/// patterns they paint with, as Paste adds those of pasted type ([`import_text_styles`]).
|
||||
fn load(s: &mut Session, p: &Value, kinds: &[Kind]) -> Result<Value> {
|
||||
let label = match kinds {
|
||||
[Kind::Char] => "Load Character Styles",
|
||||
[Kind::Para] => "Load Paragraph Styles",
|
||||
_ => "Load All Styles",
|
||||
};
|
||||
let c = &match kinds {
|
||||
[kind] => kind.cmd("load"),
|
||||
_ => "textStyle.loadAll".to_string(),
|
||||
};
|
||||
let path = str_param(p, "path");
|
||||
let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) {
|
||||
(Some(path), ..) => super::fileio::read_file(path)?,
|
||||
(None, Some(text), _) => text.as_bytes().to_vec(),
|
||||
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(c, "bad dataBase64"))?,
|
||||
_ => return Err(bad(c, "give `path`, `data` or `dataBase64`")),
|
||||
};
|
||||
let file = str_param(p, "name").or(path).unwrap_or("Styles");
|
||||
let src = super::fileio::load(file, &bytes).map_err(|e| bad(c, e.to_string()))?;
|
||||
if src.preview_only {
|
||||
return Err(bad(c, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning"));
|
||||
}
|
||||
// The source with only the styles that load. Those of a kind not loaded still come along when
|
||||
// a pattern or symbol that a loaded style paints with sets type in them.
|
||||
let mut src = src.doc;
|
||||
let mut skipped = [vec![], vec![]];
|
||||
for kind in [Kind::Char, Kind::Para] {
|
||||
let (defs, names) = loadable(&src, kind);
|
||||
*kind.defs_mut(&mut src) = defs;
|
||||
if kinds.contains(&kind) {
|
||||
skipped[usize::from(kind == Kind::Para)] = names;
|
||||
}
|
||||
}
|
||||
let (chars, paras) = (kinds.contains(&Kind::Char), kinds.contains(&Kind::Para));
|
||||
let mut trial = (*s.doc()?.doc).clone();
|
||||
let mut placed = import_text_styles(&src, &mut trial, chars, paras);
|
||||
// An undo step only when the document changes. The step takes the document the import made,
|
||||
// unless the edit first ends a typing session, which can change the document it starts from.
|
||||
if trial != *s.doc()?.doc {
|
||||
if super::typing_in_progress(s) {
|
||||
placed = s.edit(label, |d, _| Ok(import_text_styles(&src, d, chars, paras)))?;
|
||||
} else {
|
||||
s.edit(label, |d, _| {
|
||||
*d = trial;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
let reply = |para: bool| {
|
||||
let names = |added: bool| placed.iter().filter(|p| p.para == para && p.added == added).map(|p| p.name.as_str()).collect::<Vec<_>>();
|
||||
json!({ "added": names(true), "existing": names(false), "skipped": skipped[usize::from(para)] })
|
||||
};
|
||||
Ok(match kinds {
|
||||
[kind] => reply(*kind == Kind::Para),
|
||||
_ => json!({ "charStyles": reply(false), "paraStyles": reply(true) }),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -733,6 +733,480 @@ fn several_styles_are_deleted_in_one_step() {
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Delete Style");
|
||||
}
|
||||
|
||||
/// A saved document whose character and paragraph styles are `char` and `para` (name, attrs),
|
||||
/// stored as they are, as `charStyle.load`'s `dataBase64`.
|
||||
fn styles_file(char: &[(&str, Value)], para: &[(&str, Value)]) -> String {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
let defs = |v: &[(&str, Value)]| {
|
||||
v.iter().map(|(n, a)| TextStyleDef { name: n.to_string(), attrs: a.as_object().cloned().unwrap_or_default() }).collect::<Vec<_>>()
|
||||
};
|
||||
d.char_styles = defs(char).into();
|
||||
d.para_styles = defs(para).into();
|
||||
vectorcraft_format::base64_encode(&vectorcraft_format::save(&d, false))
|
||||
}
|
||||
|
||||
/// Load Character Styles and Load Paragraph Styles copy the other document's styles of their kind
|
||||
/// after the document's own, in one undo step. A style with the same attributes as the style here
|
||||
/// of its name, or of its name with a number, is not added again; any other gets the first free of
|
||||
/// those names, and the style here and the type using it keep their attributes. The file's Normal
|
||||
/// style is not loaded, and a style whose attributes do not read, or whose name is empty, is
|
||||
/// skipped.
|
||||
#[test]
|
||||
fn styles_load_from_another_document_in_one_step() {
|
||||
for p in ["charStyle", "paraStyle"] {
|
||||
let (mut s, id) = session_with_text("Title");
|
||||
let normal = if p == "charStyle" { NORMAL_CHAR } else { NORMAL_PARA };
|
||||
let key = if p == "charStyle" { "size" } else { "space_after" };
|
||||
let size = |v: f64| Value::Object(Map::from_iter([(key.to_string(), json!(v))]));
|
||||
for (n, v) in [("Body", 12.0), ("Accent", 9.0), ("Accent 2", 1.0)] {
|
||||
s.execute(&format!("{p}.new"), &json!({"name": n, "attrs": size(v)})).unwrap();
|
||||
}
|
||||
s.execute(&format!("{p}.apply"), &json!({"name": "Accent", "id": id})).unwrap();
|
||||
let file =
|
||||
[("Body", size(12.0)), ("Accent", size(20.0)), ("Fresh", size(5.0)), (normal, size(30.0)), ("Bad", json!({"nope": 1})), (" ", size(2.0))];
|
||||
let data = styles_file(&file, &file);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute(&format!("{p}.load"), &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
|
||||
assert_eq!(r, json!({"added": ["Accent 3", "Fresh"], "existing": ["Body"], "skipped": ["Bad", " "]}), "{p}");
|
||||
assert_eq!(listed(&mut s, p), [normal, "Body", "Accent", "Accent 2", "Accent 3", "Fresh"], "{p}");
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let attrs = |name: &str| super::style_attrs(&d, p == "paraStyle", name).map(Value::Object);
|
||||
assert_eq!((attrs("Accent"), attrs("Accent 3"), attrs(normal)), (Some(size(9.0)), Some(size(20.0)), Some(json!({}))), "{p}");
|
||||
// The other kind and the type are as they were.
|
||||
let (other, mine) = if p == "charStyle" { (&d.para_styles, &d.char_styles) } else { (&d.char_styles, &d.para_styles) };
|
||||
let (other_before, mine_before) =
|
||||
if p == "charStyle" { (&before.para_styles, &before.char_styles) } else { (&before.char_styles, &before.para_styles) };
|
||||
assert_eq!((other, &mine[..mine_before.len()]), (other_before, &mine_before[..]), "{p}");
|
||||
assert_eq!(d.node(NodeId(id)), before.node(NodeId(id)), "{p}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}");
|
||||
// Loading the same file again adds nothing and makes no undo step.
|
||||
let again = s.execute(&format!("{p}.load"), &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
|
||||
assert_eq!(again["added"], json!([]), "{p}");
|
||||
assert_eq!(again["existing"], json!(["Body", "Accent 3", "Fresh"]), "{p}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}");
|
||||
let undone = s.execute("edit.undo", &json!({})).unwrap()["undone"].clone();
|
||||
assert_eq!(undone, if p == "charStyle" { "Load Character Styles" } else { "Load Paragraph Styles" });
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Load All Styles loads both kinds in one undo step, from a path too; a file that does not open,
|
||||
/// or no file, loads nothing.
|
||||
#[test]
|
||||
fn load_all_styles_loads_both_kinds_in_one_step() {
|
||||
let (mut s, _) = session_with_text("Title");
|
||||
let data = styles_file(&[("Lead", json!({"size": 30.0}))], &[("Centered", json!({"justify": "Center"}))]);
|
||||
let dir = std::env::temp_dir().join(format!("vc-load-styles-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Other.vectorcraft");
|
||||
std::fs::write(&path, vectorcraft_format::base64_decode(&data).unwrap()).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for bad in [json!({}), json!({"data": "not a document", "name": "x.vectorcraft"}), json!({"dataBase64": "!"})] {
|
||||
assert!(s.execute("textStyle.loadAll", &bad).is_err(), "{bad}");
|
||||
}
|
||||
let r = s.execute("textStyle.loadAll", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
let one = |name: &str| json!({"added": [name], "existing": [], "skipped": []});
|
||||
assert_eq!(r, json!({"charStyles": one("Lead"), "paraStyles": one("Centered")}));
|
||||
assert_eq!(listed(&mut s, "charStyle"), [NORMAL_CHAR, "Lead"]);
|
||||
assert_eq!(listed(&mut s, "paraStyle"), [NORMAL_PARA, "Centered"]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Load All Styles");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
/// A loaded character style whose size, scale, leading, shift or stroke width is out of the range
|
||||
/// `charStyle.new` takes is skipped.
|
||||
#[test]
|
||||
fn loaded_styles_out_of_range_are_skipped() {
|
||||
let (mut s, _) = session_with_text("Title");
|
||||
let file = [
|
||||
("Huge", json!({"size": 2000.0})),
|
||||
("Flat", json!({"h_scale": 0.5})),
|
||||
("Shifted", json!({"baseline_shift": 5000.0})),
|
||||
("Thick", json!({"stroke_width": -2.0})),
|
||||
("Fine", json!({"size": 12.0, "h_scale": 50.0})),
|
||||
];
|
||||
let data = styles_file(&file, &[]);
|
||||
let r = s.execute("charStyle.load", &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
|
||||
assert_eq!(r, json!({"added": ["Fine"], "existing": [], "skipped": ["Huge", "Flat", "Shifted", "Thick"]}));
|
||||
assert_eq!(listed(&mut s, "charStyle"), [NORMAL_CHAR, "Fine"]);
|
||||
}
|
||||
|
||||
/// A loaded style brings the pattern it paints with, renamed when the document has another pattern
|
||||
/// of that name, as Paste brings it; a global color whose name the document gives another color
|
||||
/// is the document's.
|
||||
#[test]
|
||||
fn loaded_styles_bring_the_patterns_and_swatches_they_paint_with() {
|
||||
// A pattern "Dots" made from a `size` square, a global "Ink", and type painted with each.
|
||||
let painted = |size: f64, ink: &str| {
|
||||
let (mut s, id) = session_with_text("Dots");
|
||||
let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": size, "height": size})).unwrap()["id"].clone();
|
||||
s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": ink, "global": true})).unwrap();
|
||||
(s, id)
|
||||
};
|
||||
let (mut src, id) = painted(10.0, "#3366cc");
|
||||
src.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap();
|
||||
src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap();
|
||||
src.execute("paint.setFill", &json!({"swatch": "Ink", "ids": [id]})).unwrap();
|
||||
src.execute("charStyle.new", &json!({"name": "Inked", "id": id})).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false));
|
||||
let (mut s, id) = painted(30.0, "#ff0000");
|
||||
let r = s.execute("charStyle.load", &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
|
||||
assert_eq!(r["added"], json!(["Patterned", "Inked"]));
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let fill = |name: &str| super::style_attrs(&d, false, name).unwrap()["fill"].to_string();
|
||||
assert!(d.pattern("Dots 2").is_some() && fill("Patterned").contains("\"Dots 2\""), "{}", fill("Patterned"));
|
||||
assert!(fill("Inked").contains("\"Ink\"") && d.swatch("Ink 2").is_none(), "{}", fill("Inked"));
|
||||
s.execute("charStyle.apply", &json!({"name": "Patterned", "id": id})).unwrap();
|
||||
assert!(matches!(&runs(&s, id)[0].style.fill, vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots 2"));
|
||||
}
|
||||
|
||||
/// A pattern whose type is set in a loaded style that was renamed on the way in ("Accent 2") is
|
||||
/// the same pattern when the file is loaded again, and so is the style that paints with it, also
|
||||
/// when "Accent" itself paints with another pattern.
|
||||
#[test]
|
||||
fn reloaded_patterns_follow_the_renamed_styles_their_type_uses() {
|
||||
for patterned in [false, true] {
|
||||
let (mut src, id) = session_with_text("Tile");
|
||||
if patterned {
|
||||
let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap();
|
||||
src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap();
|
||||
}
|
||||
src.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 4, "kerning": 40})).unwrap();
|
||||
src.execute("charStyle.new", &json!({"name": "Accent", "id": id})).unwrap();
|
||||
src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap();
|
||||
if patterned {
|
||||
// A solid fill over the style's, so the tile's type itself paints with no pattern.
|
||||
src.execute("paint.setFill", &json!({"color": "#000000", "ids": [id]})).unwrap();
|
||||
}
|
||||
src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap();
|
||||
let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
|
||||
src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap();
|
||||
src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false));
|
||||
let (mut s, _) = session_with_text("X");
|
||||
s.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 10}})).unwrap();
|
||||
let load = json!({"dataBase64": data, "name": "Other.vectorcraft"});
|
||||
s.execute("charStyle.load", &load).unwrap();
|
||||
let counts = |s: &Session| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
(d.patterns.len(), d.char_styles.iter().map(|c| c.name.clone()).collect::<Vec<_>>())
|
||||
};
|
||||
let first = counts(&s);
|
||||
assert!(first.1.contains(&"Accent 2".to_string()) && first.1.contains(&"Tiled".to_string()), "{patterned}: {first:?}");
|
||||
let r = s.execute("charStyle.load", &load).unwrap();
|
||||
assert_eq!(r["added"], json!([]), "{patterned}: {r}");
|
||||
assert_eq!(counts(&s), first, "{patterned}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded style that paints with a pattern whose type is set in that same style still paints
|
||||
/// with the pattern when the pattern comes in under a free name ("Dots 2"). The commands refuse to
|
||||
/// make that loop, so the source is a document edited directly, as a file may hold it.
|
||||
#[test]
|
||||
fn a_style_and_the_pattern_that_uses_it_load_together() {
|
||||
let (mut src, id) = session_with_text("Tile");
|
||||
src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap();
|
||||
src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap();
|
||||
src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [id]})).unwrap();
|
||||
let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
|
||||
src.execute("charStyle.apply", &json!({"name": "Accent", "id": other})).unwrap();
|
||||
src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [other]})).unwrap();
|
||||
src.execute("charStyle.new", &json!({"name": "Painted", "id": other})).unwrap();
|
||||
let mut source = (*src.doc().unwrap().doc).clone();
|
||||
let painted = source.char_styles.iter().find(|s| s.name == "Painted").unwrap().attrs.clone();
|
||||
source.char_styles.retain(|s| s.name != "Painted");
|
||||
source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().attrs = painted;
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false));
|
||||
let fill = |s: &Session, name: &str| super::style_attrs(&s.doc().unwrap().doc, false, name).unwrap()["fill"].to_string();
|
||||
let source_fill = super::style_attrs(&source, false, "Accent").unwrap()["fill"].to_string();
|
||||
assert!(source_fill.contains("\"Dots\""), "{source_fill}");
|
||||
let (mut s, _) = session_with_text("X");
|
||||
let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap();
|
||||
let load = json!({"dataBase64": data, "name": "Other.vectorcraft"});
|
||||
s.execute("charStyle.load", &load).unwrap();
|
||||
assert!(s.doc().unwrap().doc.pattern("Dots 2").is_some());
|
||||
assert!(fill(&s, "Accent").contains("\"Dots 2\""), "{}", fill(&s, "Accent"));
|
||||
// Loaded again, the two are found under those names, and nothing is added.
|
||||
let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let r = s.execute("charStyle.load", &load).unwrap();
|
||||
assert_eq!(r["added"], json!([]), "{r}");
|
||||
assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), (doc, undo));
|
||||
}
|
||||
|
||||
/// A style and the pattern it paints with, whose type is set in that style, are both added when
|
||||
/// the document has a style of that name and attributes but another pattern of that name: the
|
||||
/// copy of the style paints with the copy of the pattern, and a second load adds nothing. A loop the
|
||||
/// document has is used under the names it has together.
|
||||
#[test]
|
||||
fn a_style_in_a_loop_is_not_matched_to_a_style_that_paints_with_another_pattern() {
|
||||
let (mut src, id) = session_with_text("Tile");
|
||||
src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap();
|
||||
src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap();
|
||||
src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [id]})).unwrap();
|
||||
let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
|
||||
src.execute("charStyle.apply", &json!({"name": "Accent", "id": other})).unwrap();
|
||||
src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [other]})).unwrap();
|
||||
src.execute("charStyle.new", &json!({"name": "Painted", "id": other})).unwrap();
|
||||
let mut source = (*src.doc().unwrap().doc).clone();
|
||||
let painted = source.char_styles.iter().find(|s| s.name == "Painted").unwrap().attrs.clone();
|
||||
source.char_styles.retain(|s| s.name != "Painted");
|
||||
source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().attrs = painted.clone();
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false));
|
||||
let (mut s, _) = session_with_text("X");
|
||||
let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
doc.char_styles.push(TextStyleDef { name: "Accent".into(), attrs: painted });
|
||||
let load = json!({"dataBase64": data, "name": "Other.vectorcraft"});
|
||||
let r = s.execute("charStyle.load", &load).unwrap();
|
||||
assert_eq!(r["added"], json!(["Accent 2"]), "{r}");
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let fill = |name: &str| super::style_attrs(&d, false, name).unwrap()["fill"].to_string();
|
||||
assert!(fill("Accent").contains("\"Dots\"") && fill("Accent 2").contains("\"Dots 2\""), "{} {}", fill("Accent"), fill("Accent 2"));
|
||||
let NodeKind::Text(text) = &d.pattern("Dots 2").unwrap().art[0].kind else { panic!("pattern art should be type") };
|
||||
assert_eq!(text.runs[0].style.style_name.as_deref(), Some("Accent 2"));
|
||||
let r = s.execute("charStyle.load", &load).unwrap();
|
||||
assert_eq!(r["added"], json!([]), "{r}");
|
||||
assert_eq!(s.doc().unwrap().doc, d);
|
||||
// With the loop's style renamed "Accent 3" and an "Accent 2" listed before it that also paints
|
||||
// with "Dots 2", the loop is still found as "Accent 3" and "Dots 2", the names it has together.
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let at = doc.char_styles.iter().position(|c| c.name == "Accent 2").unwrap();
|
||||
doc.char_styles[at].name = "Accent 3".into();
|
||||
let copy = TextStyleDef { name: "Accent 2".into(), attrs: doc.char_styles[at].attrs.clone() };
|
||||
doc.char_styles.insert(at, copy);
|
||||
let tile = doc.patterns.iter_mut().find(|p| p.name == "Dots 2").unwrap();
|
||||
let NodeKind::Text(text) = &mut std::sync::Arc::make_mut(&mut tile.art[0]).kind else { panic!("pattern art should be type") };
|
||||
text.runs[0].style.style_name = Some("Accent 3".into());
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let r = s.execute("charStyle.load", &load).unwrap();
|
||||
assert_eq!(r, json!({"added": [], "existing": ["Accent 3"], "skipped": []}));
|
||||
assert_eq!(s.doc().unwrap().doc, d);
|
||||
}
|
||||
|
||||
/// A brush in a loaded style's pattern whose art sets type in a style renamed on the way in
|
||||
/// ("Accent 2") is the same brush when the file is loaded again.
|
||||
#[test]
|
||||
fn reloaded_brushes_follow_the_renamed_styles_their_art_uses() {
|
||||
let (mut src, id) = session_with_text("Ink");
|
||||
src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap();
|
||||
src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap();
|
||||
src.execute("brush.new", &json!({"type": "art", "name": "Lettering", "ids": [id]})).unwrap();
|
||||
let path = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 40, "height": 10})).unwrap()["id"].clone();
|
||||
src.execute("brush.apply", &json!({"name": "Lettering", "ids": [path]})).unwrap();
|
||||
src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [path]})).unwrap();
|
||||
let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
|
||||
src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap();
|
||||
src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false));
|
||||
let (mut s, _) = session_with_text("X");
|
||||
s.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 10}})).unwrap();
|
||||
let load = json!({"dataBase64": data, "name": "Other.vectorcraft"});
|
||||
let r = s.execute("charStyle.load", &load).unwrap();
|
||||
assert!(r["added"].as_array().unwrap().contains(&json!("Accent 2")), "{r}");
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let lib = vectorcraft_brush::library(&d);
|
||||
let brush = lib.iter().find(|b| b.name == "Lettering").unwrap();
|
||||
let mut styles = vec![];
|
||||
for a in brush.art() {
|
||||
a.walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
styles.extend(t.runs.iter().filter_map(|r| r.style.style_name.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
assert_eq!(styles, ["Accent 2"]);
|
||||
let r = s.execute("charStyle.load", &load).unwrap();
|
||||
assert_eq!(r["added"], json!([]), "{r}");
|
||||
assert_eq!(s.doc().unwrap().doc, d);
|
||||
}
|
||||
|
||||
/// A style name from a saved document stays the name used by type in imported pattern art.
|
||||
#[test]
|
||||
fn loaded_style_keeps_the_name_used_by_pattern_type() {
|
||||
let (mut src, id) = session_with_text("Tile");
|
||||
src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap();
|
||||
src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap();
|
||||
src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap();
|
||||
let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
|
||||
src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap();
|
||||
src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap();
|
||||
let mut source = (*src.doc().unwrap().doc).clone();
|
||||
source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().name = " Accent ".into();
|
||||
let tile = source.patterns.iter_mut().find(|p| p.name == "Tiles").unwrap();
|
||||
let art = std::sync::Arc::make_mut(&mut tile.art[0]);
|
||||
let NodeKind::Text(text) = &mut art.kind else { panic!("pattern art should be type") };
|
||||
text.runs[0].style.style_name = Some(" Accent ".into());
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false));
|
||||
let (mut dst, _) = session_with_text("X");
|
||||
let load = json!({"dataBase64": data, "name": "Other.vectorcraft"});
|
||||
dst.execute("charStyle.load", &load).unwrap();
|
||||
let doc = &dst.doc().unwrap().doc;
|
||||
assert!(doc.char_styles.iter().any(|s| s.name == " Accent "));
|
||||
let NodeKind::Text(text) = &doc.pattern("Tiles").unwrap().art[0].kind else { panic!("pattern art should be type") };
|
||||
assert_eq!(text.runs[0].style.style_name.as_deref(), Some(" Accent "));
|
||||
}
|
||||
|
||||
/// Load Character Styles brings the paragraph style that type in a loaded style's pattern is set
|
||||
/// in, renamed when the document has another style of that name, without listing it in the reply.
|
||||
#[test]
|
||||
fn loaded_pattern_type_keeps_its_paragraph_style() {
|
||||
let (mut src, id) = session_with_text("Tile");
|
||||
src.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center"}})).unwrap();
|
||||
src.execute("paraStyle.apply", &json!({"name": "Centered", "id": id})).unwrap();
|
||||
src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap();
|
||||
let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
|
||||
src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap();
|
||||
src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false));
|
||||
let (mut s, _) = session_with_text("X");
|
||||
s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Right"}})).unwrap();
|
||||
let load = json!({"dataBase64": data, "name": "Other.vectorcraft"});
|
||||
let r = s.execute("charStyle.load", &load).unwrap();
|
||||
assert_eq!(r, json!({"added": ["Tiled"], "existing": [], "skipped": []}));
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert_eq!(listed(&mut s, "paraStyle"), [NORMAL_PARA, "Centered", "Centered 2"]);
|
||||
assert_eq!(super::style_attrs(&d, true, "Centered 2").map(Value::Object), Some(json!({"justify": "Center"})));
|
||||
let NodeKind::Text(text) = &d.pattern("Tiles").unwrap().art[0].kind else { panic!("pattern art should be type") };
|
||||
assert_eq!(text.para_styles().filter_map(|p| p.style_name.as_deref()).collect::<Vec<_>>(), ["Centered 2"]);
|
||||
// Loaded again, nothing is added.
|
||||
let r = s.execute("charStyle.load", &load).unwrap();
|
||||
assert_eq!(r["added"], json!([]), "{r}");
|
||||
assert_eq!(s.doc().unwrap().doc, d);
|
||||
}
|
||||
|
||||
/// A pattern whose tile paints with itself is found under its renamed copy on a second load.
|
||||
#[test]
|
||||
fn self_referencing_pattern_is_not_added_again() {
|
||||
let (mut src, id) = session_with_text("Patterned");
|
||||
let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap();
|
||||
let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [tile]})).unwrap();
|
||||
let art = src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone();
|
||||
assert!(art.appearance.items.iter().any(|item| matches!(item.paint(), vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots")));
|
||||
let mut source = (*src.doc().unwrap().doc).clone();
|
||||
source.patterns.iter_mut().find(|p| p.name == "Dots").unwrap().art = vec![std::sync::Arc::new(art)];
|
||||
src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap();
|
||||
src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap();
|
||||
source.char_styles = src.doc().unwrap().doc.char_styles.clone();
|
||||
assert!(super::style_attrs(&source, false, "Patterned").unwrap()["fill"].to_string().contains("\"Dots\""));
|
||||
let (mut dst, _) = session_with_text("X");
|
||||
let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
dst.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [different]})).unwrap();
|
||||
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
|
||||
super::super::clipboard::resources::import_text_styles(&source, target, true, true);
|
||||
assert!(dst.doc().unwrap().doc.pattern("Dots 2").is_some());
|
||||
assert!(
|
||||
dst.doc().unwrap().doc.pattern("Dots 2").unwrap().art[0]
|
||||
.appearance
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| matches!(item.paint(), vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots 2"))
|
||||
);
|
||||
let before = dst.doc().unwrap().doc.clone();
|
||||
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
|
||||
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true);
|
||||
assert!(again.iter().all(|s| !s.added));
|
||||
assert_eq!(dst.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
/// Patterns whose tiles each paint with themselves are matched one loop at a time: nine of them,
|
||||
/// each in the document under its name with other art and as the copy an earlier load added,
|
||||
/// are found under those copies, and a second load adds nothing.
|
||||
#[test]
|
||||
fn separate_self_referencing_patterns_are_not_added_again() {
|
||||
let (mut src, id) = session_with_text("Patterned");
|
||||
let mut tiles = vec![];
|
||||
for k in 0..9 {
|
||||
let name = format!("Dots{k}");
|
||||
let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
src.execute("object.pattern.make", &json!({"name": name, "edit": false, "ids": [square]})).unwrap();
|
||||
let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
src.execute("paint.setFill", &json!({"swatch": name, "ids": [tile]})).unwrap();
|
||||
tiles.push((name.clone(), src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone()));
|
||||
src.execute("paint.setFill", &json!({"swatch": name, "ids": [id]})).unwrap();
|
||||
src.execute("charStyle.new", &json!({"name": format!("Patterned{k}"), "id": id})).unwrap();
|
||||
}
|
||||
let mut source = (*src.doc().unwrap().doc).clone();
|
||||
for (name, art) in tiles {
|
||||
source.patterns.iter_mut().find(|p| p.name == name).unwrap().art = vec![std::sync::Arc::new(art)];
|
||||
}
|
||||
let (mut dst, _) = session_with_text("X");
|
||||
for k in 0..9 {
|
||||
let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap();
|
||||
}
|
||||
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
|
||||
super::super::clipboard::resources::import_text_styles(&source, target, true, true);
|
||||
for k in 0..9 {
|
||||
assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}");
|
||||
}
|
||||
let before = dst.doc().unwrap().doc.clone();
|
||||
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
|
||||
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true);
|
||||
assert!(again.iter().all(|s| !s.added));
|
||||
assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len());
|
||||
assert_eq!(dst.doc().unwrap().doc, before);
|
||||
// With one loop's pattern gone from the document, the others are still found.
|
||||
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
|
||||
target.patterns.retain(|p| !p.name.starts_with("Dots8"));
|
||||
let left = target.patterns.len();
|
||||
let third = super::super::clipboard::resources::import_text_styles(&source, target, true, true);
|
||||
assert_eq!(dst.doc().unwrap().doc.patterns.len(), left + 1);
|
||||
assert!(third.iter().filter(|s| s.name != "Patterned8" && !s.name.starts_with("Patterned8 ")).all(|s| !s.added));
|
||||
}
|
||||
|
||||
/// Nine patterns whose tiles each paint with the next one, the last with the first, in the
|
||||
/// document under their names with other art and as the copies an earlier load added, are found
|
||||
/// under those copies, and a second load adds nothing.
|
||||
#[test]
|
||||
fn a_long_loop_of_patterns_is_not_added_again() {
|
||||
let (mut src, id) = session_with_text("Patterned");
|
||||
for k in 0..9 {
|
||||
let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
src.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [square]})).unwrap();
|
||||
}
|
||||
let mut tiles = vec![];
|
||||
for k in 0..9 {
|
||||
let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
src.execute("paint.setFill", &json!({"swatch": format!("Dots{}", (k + 1) % 9), "ids": [tile]})).unwrap();
|
||||
tiles.push(src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone());
|
||||
}
|
||||
src.execute("paint.setFill", &json!({"swatch": "Dots0", "ids": [id]})).unwrap();
|
||||
src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap();
|
||||
let mut source = (*src.doc().unwrap().doc).clone();
|
||||
for (k, art) in tiles.into_iter().enumerate() {
|
||||
source.patterns.iter_mut().find(|p| p.name == format!("Dots{k}")).unwrap().art = vec![std::sync::Arc::new(art)];
|
||||
}
|
||||
let (mut dst, _) = session_with_text("X");
|
||||
for k in 0..9 {
|
||||
let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap();
|
||||
}
|
||||
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
|
||||
super::super::clipboard::resources::import_text_styles(&source, target, true, true);
|
||||
for k in 0..9 {
|
||||
assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}");
|
||||
}
|
||||
let before = dst.doc().unwrap().doc.clone();
|
||||
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
|
||||
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true);
|
||||
assert!(again.iter().all(|s| !s.added));
|
||||
assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len());
|
||||
assert_eq!(dst.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
/// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no
|
||||
/// mojikumi) are still attributes a style can set, and a style captured with one applies to
|
||||
/// text at the default, is duplicated and comes back out of a library.
|
||||
|
||||
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Outlines",
|
||||
["Type"],
|
||||
Some("Cmd+Shift+O"),
|
||||
"{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows → {ids}",
|
||||
"{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}",
|
||||
has_selection,
|
||||
create_outlines
|
||||
),
|
||||
@@ -298,6 +298,18 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) {
|
||||
t.cached_baselines = lay.baselines();
|
||||
}
|
||||
|
||||
/// [`refresh_bounds`] for a document that is opening: the first frame takes the layout
|
||||
/// ([`vectorcraft_text::take_kept_layout`]) instead of laying the object out again.
|
||||
pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) {
|
||||
t.normalize_paras();
|
||||
auto_height(t);
|
||||
let stamp = vectorcraft_text::LayoutStamp::now();
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
t.cached_bounds = Some(lay.bounds);
|
||||
t.cached_baselines = lay.baselines();
|
||||
vectorcraft_text::keep_layout(t, stamp, lay);
|
||||
}
|
||||
|
||||
/// The text-space bounds of area type's frame, when Auto Size can fit it: a rectangle (one
|
||||
/// subpath, as large as its bounds) of horizontal type in one row, with some height.
|
||||
fn auto_height_frame(t: &TextObject) -> Option<vectorcraft_geom::Rect> {
|
||||
@@ -432,7 +444,7 @@ fn columns_hold(lines: &[vectorcraft_text::LineInfo], cols: usize, y0: f64, inse
|
||||
/// Transform panel and hit testing would fall back to [`TextObject::estimate_bounds`] (a rough
|
||||
/// 0.55 em per character) until the text is edited — the box comes up short and alignment looks off.
|
||||
pub(crate) fn refresh_bounds_of(d: &mut Document, ids: &[NodeId]) {
|
||||
for id in text_ids(d, ids) {
|
||||
for id in text_ids_where(d, ids, |t| t.cached_bounds.is_none()) {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
|
||||
&& t.cached_bounds.is_none()
|
||||
{
|
||||
@@ -498,11 +510,22 @@ fn overflow_note(ids: &[NodeId], left_out: &str) -> Option<String> {
|
||||
|
||||
/// Text objects among `ids` and their descendants.
|
||||
fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
text_ids_where(d, ids, |_| true)
|
||||
}
|
||||
|
||||
/// The text objects among `ids` and their descendants that `keep` accepts, each once, in the order
|
||||
/// they are found. All of `ids` are looked up in one walk of the tree.
|
||||
fn text_ids_where(d: &Document, ids: &[NodeId], keep: impl Fn(&TextObject) -> bool) -> Vec<NodeId> {
|
||||
let paths = d.index_paths(ids.iter().copied());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
if let Some(n) = d.node(*id) {
|
||||
if let Some(n) = paths.get(id).and_then(|p| d.node_at(p)) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) {
|
||||
if let NodeKind::Text(t) = &c.kind
|
||||
&& keep(t)
|
||||
&& seen.insert(c.id)
|
||||
{
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
@@ -554,6 +577,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if texts.is_empty() {
|
||||
return Err(EngineError::Other("Create Outlines: select text objects".into()));
|
||||
}
|
||||
let ended = super::dimension::end_links(d, &roots);
|
||||
let mut new_sel: Vec<NodeId> = roots.iter().copied().filter(|r| !texts.contains(r)).collect();
|
||||
let mut out = vec![];
|
||||
let threaded = super::threads::frames(d);
|
||||
@@ -648,6 +672,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
new_sel.push(gid);
|
||||
}
|
||||
}
|
||||
super::dimension::restore_unchanged(d, ended);
|
||||
// Outlined frames of threaded text leave their threads with the text they show.
|
||||
super::threads::leave_threads(d, &threaded);
|
||||
sel.set(new_sel);
|
||||
|
||||
@@ -6,9 +6,11 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{CharStyle, Document, Justify, LockedArtboards, Node, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::object::choice;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, find_command, has_clipboard, has_doc, has_selection, ids_param, str_param, targets,
|
||||
CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or,
|
||||
switch_or, targets, text_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -108,11 +110,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
/// Text objects among `ids` and their descendants.
|
||||
pub(crate) fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let paths = d.index_paths(ids.iter().copied());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
if let Some(n) = d.node(*id) {
|
||||
if let Some(n) = paths.get(id).and_then(|p| d.node_at(p)) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) {
|
||||
if matches!(c.kind, NodeKind::Text(_)) && seen.insert(c.id) {
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
@@ -180,7 +184,7 @@ struct CaseState {
|
||||
|
||||
fn change_case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.changeCase";
|
||||
let case = str_param(p, "case").ok_or_else(|| bad(C, "missing case"))?.to_ascii_lowercase();
|
||||
let case = text_param(p, "case", C)?.ok_or_else(|| bad(C, "missing case"))?.to_ascii_lowercase();
|
||||
if !["upper", "uppercase", "lower", "lowercase", "title", "sentence"].contains(&case.as_str()) {
|
||||
return Err(bad(C, "case must be upper|lower|title|sentence"));
|
||||
}
|
||||
@@ -251,11 +255,14 @@ pub(crate) fn smarten(text: &str, prev: &mut Option<char>, quotes: Option<&Quote
|
||||
}
|
||||
|
||||
fn smart_punctuation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (d, e) = (bool_or(p, "dashes", true), bool_or(p, "ellipsis", true));
|
||||
const C: &str = "type.smartPunctuation";
|
||||
let (d, e) = (switch_or(p, "dashes", true, C)?, switch_or(p, "ellipsis", true, C)?);
|
||||
let quotes = switch_or(p, "quotes", true, C)?;
|
||||
let document = choice(p, C, "scope", &["selection", "document"])? == Some("document");
|
||||
let st = s.doc()?;
|
||||
// Quotes in the document's style (Document Setup → Type).
|
||||
let q = bool_or(p, "quotes", true).then_some(st.doc.setup.quotes);
|
||||
let ids = if str_param(p, "scope") == Some("document") || st.selection.is_empty() {
|
||||
let q = quotes.then_some(st.doc.setup.quotes);
|
||||
let ids = if document || st.selection.is_empty() {
|
||||
let mut v = vec![];
|
||||
st.doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Text(_)) {
|
||||
@@ -434,16 +441,19 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let start = fraction("start")?;
|
||||
// `end: null` puts the end bracket back at the end of the path.
|
||||
let end = p.get("end").map(|_| fraction("end")).transpose()?;
|
||||
let flip = bool_or(p, "flip", false);
|
||||
let effect = match str_param(p, "effect") {
|
||||
let flip = switch_or(p, "flip", false, C)?;
|
||||
let effect = match text_param(p, "effect", C)? {
|
||||
Some(e) => Some(vectorcraft_doc::PathEffect::parse(e).ok_or_else(|| bad(C, format!("unknown effect `{e}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let align = match str_param(p, "alignToPath") {
|
||||
let align = match text_param(p, "alignToPath", C)? {
|
||||
Some(a) => Some(vectorcraft_doc::PathAlign::parse(a).ok_or_else(|| bad(C, format!("unknown alignToPath `{a}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let spacing = p.get("spacing").and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(-1000.0, 1000.0));
|
||||
let spacing = match p.get("spacing") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(number_or(p, "spacing", 0.0, C)?.clamp(-1000.0, 1000.0)),
|
||||
};
|
||||
// Without a flip, when every object already has every value given, the document and its undo
|
||||
// history stay as they are.
|
||||
let has_values = |t: &TextObject| match &t.kind {
|
||||
@@ -606,16 +616,21 @@ fn unicode_char(u: &str) -> Option<char> {
|
||||
|
||||
fn insert_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.insert";
|
||||
let text = match (str_param(p, "char"), str_param(p, "text")) {
|
||||
(Some(c), _) => special_char(c).ok_or_else(|| bad(C, format!("unknown character `{c}`")))?.to_string(),
|
||||
(None, Some(t)) => t.to_string(),
|
||||
_ => match (p.get("code"), str_param(p, "unicode")) {
|
||||
(Some(code), _) => alt_code_char(code.as_u64().ok_or_else(|| bad(C, "`code` must be a whole number"))?)
|
||||
.ok_or_else(|| bad(C, format!("no character has code {code}")))?
|
||||
.to_string(),
|
||||
(None, Some(u)) => unicode_char(u).ok_or_else(|| bad(C, format!("`{u}` is no Unicode character")))?.to_string(),
|
||||
_ => return Err(bad(C, "give `char`, `text`, `code` or `unicode`")),
|
||||
},
|
||||
// Every source is type-checked before one is chosen, so a bad value is refused even when a
|
||||
// source ahead of it is given.
|
||||
let ch = text_param(p, "char", C)?;
|
||||
let txt = text_param(p, "text", C)?;
|
||||
let code = match p.get("code").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| bad(C, "`code` must be a whole number"))?),
|
||||
None => None,
|
||||
};
|
||||
let uni = text_param(p, "unicode", C)?;
|
||||
let text = match (ch, txt, code, uni) {
|
||||
(Some(c), ..) => special_char(c).ok_or_else(|| bad(C, format!("unknown character `{c}`")))?.to_string(),
|
||||
(None, Some(t), ..) => t.to_string(),
|
||||
(None, None, Some(code), _) => alt_code_char(code).ok_or_else(|| bad(C, format!("no character has code {code}")))?.to_string(),
|
||||
(None, None, None, Some(u)) => unicode_char(u).ok_or_else(|| bad(C, format!("`{u}` is no Unicode character")))?.to_string(),
|
||||
(None, None, None, None) => return Err(bad(C, "give `char`, `text`, `code` or `unicode`")),
|
||||
};
|
||||
let ids = texts(s, p, C)?;
|
||||
// When `text` is empty and every object ends with a run of text, the document and its undo
|
||||
@@ -720,11 +735,11 @@ fn all_texts(d: &Document) -> Vec<NodeId> {
|
||||
|
||||
fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.findReplace";
|
||||
let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string();
|
||||
let rep = str_param(p, "replace").unwrap_or("").to_string();
|
||||
let (mc, ww) = (bool_or(p, "matchCase", false), bool_or(p, "wholeWord", false));
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(v) => text_ids(&s.doc()?.doc, &v),
|
||||
let find = text_param(p, "find", C)?.filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string();
|
||||
let rep = text_param(p, "replace", C)?.unwrap_or("").to_string();
|
||||
let (mc, ww) = (switch_or(p, "matchCase", false, C)?, switch_or(p, "wholeWord", false, C)?);
|
||||
let ids = match p.get("ids").filter(|v| !v.is_null()) {
|
||||
Some(_) => text_ids(&s.doc()?.doc, &checked_ids_param(s, p, "ids", C)?),
|
||||
None => all_texts(&s.doc()?.doc),
|
||||
};
|
||||
let st = s.doc()?;
|
||||
@@ -777,8 +792,8 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.findNext";
|
||||
let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string();
|
||||
let (mc, ww) = (bool_or(p, "matchCase", false), bool_or(p, "wholeWord", false));
|
||||
let find = text_param(p, "find", C)?.filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string();
|
||||
let (mc, ww) = (switch_or(p, "matchCase", false, C)?, switch_or(p, "wholeWord", false, C)?);
|
||||
let st = s.doc()?;
|
||||
let all = all_texts(&st.doc);
|
||||
let cur = st.selection.objects.first().and_then(|id| all.iter().position(|x| x == id));
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} move artboards (and the art fully inside them; locked and hidden art, and art also on another locked artboard, only with prefs moveLockedWithArtboard), several in one step (an error when one is locked); `copy` (Alt-drag) leaves them and moves copies, which start unlocked → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}",
|
||||
"{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (default: prefs moveLockedWithArtboard)} move artboards (and the art fully inside them; locked and hidden art, and art also on another locked artboard, only with lockedAndHidden), several in one step (an error when one is locked); `copy` (Alt-drag) leaves them and moves copies, which start unlocked → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}",
|
||||
has_doc,
|
||||
artboard_move
|
||||
),
|
||||
@@ -384,13 +384,14 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let move_art = bool_or(p, "moveArt", false);
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let locked_art = super::panelcmds::locked_and_hidden(s, p, move_art, "artboard.move")?;
|
||||
if !copy {
|
||||
super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?;
|
||||
}
|
||||
// Each artboard's art, an object counted once (artboards can overlap).
|
||||
let mut arts: Vec<(usize, Vec<NodeId>)> = vec![];
|
||||
for &i in &indices {
|
||||
let art = super::panelcmds::artboard_art(s, i, move_art, !copy)?;
|
||||
let art = super::panelcmds::artboard_art(s, i, move_art, locked_art, !copy)?;
|
||||
let art = art.into_iter().filter(|id| !arts.iter().any(|(_, a)| a.contains(id))).collect();
|
||||
arts.push((i, art));
|
||||
}
|
||||
|
||||
@@ -27,8 +27,9 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo
|
||||
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the artboards the
|
||||
/// Artboard tool had selected for its commands; the view's center the app passes to Edit Symbol);
|
||||
/// an action leaves them out ([`Session::journal_for_action`]).
|
||||
const REPLAY_ONLY: [(&str, &str); 21] = [
|
||||
const REPLAY_ONLY: [(&str, &str); 22] = [
|
||||
("file.new", "created"),
|
||||
("edit.newDocumentFromState", "created"),
|
||||
("document.save", "modified"),
|
||||
("file.saveAs", "modified"),
|
||||
("edit.pasteInPlace", "artboard"),
|
||||
|
||||
@@ -102,6 +102,24 @@ pub struct DocState {
|
||||
/// paths the document links to are looked at within them later too, when the Links panel
|
||||
/// asks about a link outside any caller's scope.
|
||||
pub opened_by: Option<Arc<file_access::AutomationRoots>>,
|
||||
/// What [`DocState::any_empty_artboards`] last measured. Not saved.
|
||||
empty_artboards: std::cell::RefCell<EmptyArtboards>,
|
||||
}
|
||||
|
||||
/// Whether a document has an artboard for Delete Empty Artboards to delete, with the document that
|
||||
/// was measured.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct EmptyArtboards {
|
||||
/// The document measured. While this reference lasts, its allocation holds no other document,
|
||||
/// and `Arc::make_mut` moves a document it changes to a new allocation.
|
||||
doc: std::sync::Weak<Document>,
|
||||
any: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: how many times [`DocState::any_empty_artboards`] measured a document on this thread.
|
||||
pub(crate) static EMPTY_ARTBOARD_MEASURES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -280,8 +298,21 @@ impl DocState {
|
||||
import_losses: vec![],
|
||||
last_perspective: None,
|
||||
opened_by: file_access::current(),
|
||||
empty_artboards: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `measure`'s answer for the document now. `measure` runs once for each version of the document,
|
||||
/// so a menu that asks on every frame measures it once, and a change of the selection or the view
|
||||
/// keeps the answer.
|
||||
pub(crate) fn any_empty_artboards(&self, measure: impl FnOnce(&Document) -> bool) -> bool {
|
||||
let mut last = self.empty_artboards.borrow_mut();
|
||||
if !std::ptr::eq(last.doc.as_ptr(), Arc::as_ptr(&self.doc)) {
|
||||
#[cfg(test)]
|
||||
EMPTY_ARTBOARD_MEASURES.with(|c| c.set(c.get() + 1));
|
||||
*last = EmptyArtboards { doc: Arc::downgrade(&self.doc), any: measure(&self.doc) };
|
||||
}
|
||||
last.any
|
||||
}
|
||||
/// Unsaved changes: the document differs from the saved one (selection changes and changes that
|
||||
/// are not edits to save don't count, [`DocState::same_content`]).
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
|
||||
@@ -155,6 +155,17 @@ impl History {
|
||||
self.undo.iter().chain(&self.redo).map(|e| e.bytes).sum()
|
||||
}
|
||||
|
||||
/// The document at row `row` of the History panel, `now` being the document now: the rows before
|
||||
/// it are the undo steps, oldest first, and the rows after it the redo steps, nearest first.
|
||||
pub(crate) fn state<'a>(&'a self, now: &'a Arc<Document>, row: usize) -> Option<&'a Arc<Document>> {
|
||||
let current = self.undo.len();
|
||||
match row.cmp(¤t) {
|
||||
std::cmp::Ordering::Less => self.undo.get(row).map(|e| &e.doc),
|
||||
std::cmp::Ordering::Equal => Some(now),
|
||||
std::cmp::Ordering::Greater => self.redo.len().checked_sub(row - current).and_then(|i| self.redo.get(i)).map(|e| &e.doc),
|
||||
}
|
||||
}
|
||||
|
||||
/// Let the oldest steps go while over the step limit or the memory budget: undo steps first
|
||||
/// (the oldest), then redo steps (the furthest ahead).
|
||||
pub fn trim(&mut self) {
|
||||
|
||||
@@ -70,6 +70,9 @@ pub struct Session {
|
||||
/// cursor asked for again over an unchanged document and selection measures nothing again.
|
||||
pub(crate) preview_memo: vectorcraft_tools::select::PreviewMemo,
|
||||
depth: u32,
|
||||
/// Set while saved steps play ([`Session::replay`]): [`Session::execute`] then takes keys the
|
||||
/// command's params description doesn't name instead of refusing them.
|
||||
replaying: bool,
|
||||
/// Set when the active tool panicked (see [`guard`]); reported by the next tool event.
|
||||
tool_panic: Option<EngineError>,
|
||||
/// How many times switching or closing documents, or replacing the active one, has started the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user