Merge main into fix/graph-series-group-opacity

This commit is contained in:
dotCooCoo
2026-11-14 05:57:05 -08:00
181 changed files with 8726 additions and 817 deletions
Generated
+1
View File
@@ -6823,6 +6823,7 @@ dependencies = [
"skrifa 0.47.0",
"svgtypes 0.16.1",
"thiserror 2.0.21",
"unicode-bidi",
"usvg 0.48.1",
"vectorcraft-brush",
"vectorcraft-color",
+2
View File
@@ -124,6 +124,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-11-14: Built-in symbol library Weather Icons: adds a sixth procedural symbol library (Window › Symbol Libraries, the Symbols panel's Symbol Libraries Menu) containing six original vector symbols (Sun, Moon, Cloud, Rain, Thunder, Snow) generated in code without default 9-slice guides (part of Gap 5). Headline numbers unchanged.
- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged.
- 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged.
- 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged.
+3 -26
View File
@@ -108,37 +108,14 @@ impl eframe::App for App {
self.app.start_pending_update();
#[cfg(target_os = "macos")]
super::mac_cursor::release();
// Dropping the app can wait for the clipboard handles to let go: the windows leave the
// screen first (`quit_windows`).
#[cfg(not(any(target_os = "macos", windows)))]
hide_before_teardown(self.winit_window.as_deref());
super::quit_windows::before_teardown(self.winit_window.as_deref());
end_before_teardown(self.frames);
}
}
/// The main window as [`hide_before_teardown`] sees it.
#[cfg(not(any(target_os = "macos", windows)))]
pub(crate) trait QuittingWindow {
fn hide(&self);
}
#[cfg(not(any(target_os = "macos", windows)))]
impl QuittingWindow for winit::window::Window {
fn hide(&self) {
self.set_visible(false);
}
}
/// On Linux and the BSDs, hide the main window before eframe drops the app. Dropping the app waits
/// up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the clipboard handles to let go, and
/// the window would stay on screen for that time. The window is hidden, not closed: the Wayland
/// clipboard reads text on the window's connection. winit hides X11 windows (XWayland included) and
/// cannot hide a Wayland window.
#[cfg(not(any(target_os = "macos", windows)))]
pub(crate) fn hide_before_teardown<W: QuittingWindow + ?Sized>(window: Option<&W>) {
if let Some(window) = window {
window.hide();
}
}
impl App {
/// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a
/// focused, shown window whose windows had input waits for the renderer and the next frame,
+7 -3
View File
@@ -60,6 +60,8 @@ mod portal_request;
mod portal_save;
mod prefs_dir;
mod printing;
#[cfg(not(any(target_os = "macos", windows)))]
mod quit_windows;
#[cfg(not(target_os = "macos"))]
mod single_instance;
#[cfg(windows)]
@@ -90,8 +92,6 @@ mod titlebar;
use app::bring_window_forward;
use app::native_input_hook;
use app::open_or_hold;
#[cfg(all(test, not(any(target_os = "macos", windows))))]
use app::{QuittingWindow, hide_before_teardown};
use host_services::services;
#[cfg(all(test, feature = "wgpu"))]
use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes};
@@ -122,7 +122,7 @@ struct App {
/// Keeps the custom title bar's window borderless (Windows, #1758).
window_frame: Option<window_frame::WindowFrameFn>,
/// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635),
/// and to hide the window on quit before the clipboard handles let go (`app::hide_before_teardown`).
/// and to take the window off screen on quit before the clipboard handles let go (`quit_windows`).
winit_window: Option<std::sync::Arc<winit::window::Window>>,
/// A saved or fitted geometry asked for maximize once the window is mapped (Linux).
pending_maximize: bool,
@@ -418,6 +418,10 @@ fn main() -> std::process::ExitCode {
}));
options
};
// Quitting takes the X11 windows of floating panels and documents off screen with the main
// window, so eframe's windows are recorded as it makes and drops them (`quit_windows`).
#[cfg(target_os = "linux")]
let options = eframe::NativeOptions { viewport_hooks: Some(std::sync::Arc::new(quit_windows::Hooks)), ..options };
#[cfg(feature = "wgpu")]
let options = {
let mut options = options;
+6 -5
View File
@@ -242,7 +242,7 @@ fn parent(window: &winit::window::Window) -> (String, Option<()>) {
/// (<https://wayland.app/protocols/xdg-foreign-unstable-v2>), made as rfd's `window_identifier`
/// makes it.
#[cfg(target_os = "linux")]
mod wayland {
pub(crate) mod wayland {
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::protocol::wl_surface::WlSurface;
@@ -308,9 +308,10 @@ mod wayland {
}
}
/// `window`'s Wayland connection and surface, from its raw handles.
/// `window`'s Wayland connection and surface, from its raw handles. The caller holds `window`
/// while it uses them (`quit_windows` unmaps the main window's surface through them).
#[allow(unsafe_code)]
fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> {
pub(crate) fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> {
use wayland_backend::sys::client::{Backend, ObjectId};
use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle};
let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) =
@@ -319,8 +320,8 @@ mod wayland {
return None;
};
// SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller
// holds it until the export is dropped); a backend made from a foreign display does not
// close it when dropped.
// holds it while it uses the connection, and the export holds it until it is dropped); a
// backend made from a foreign display does not close it when dropped.
let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) };
let conn = Connection::from_backend(backend);
// SAFETY: the pointer is the window's live `wl_surface` proxy on that display.
+280
View File
@@ -0,0 +1,280 @@
//! Takes the app's windows off screen when it quits on Linux and the BSDs, before eframe drops the
//! app. Dropping the app waits up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the
//! clipboard handles to let go, and eframe closes the windows only after that wait.
//!
//! The main window is hidden on X11 (XWayland included). winit cannot hide a Wayland window, so on
//! Linux a Wayland main window is unmapped instead: its surface gets no buffer, and the window stays
//! open with the connection the Wayland clipboard copies and reads on. On Linux the X11 windows of
//! floating panels and documents are recorded as eframe creates and drops them ([`Hooks`]), and
//! quitting unmaps them through an X11 connection of its own. On native Wayland, floating panels and
//! documents are normally drawn inside the main window.
#[cfg(target_os = "linux")]
use std::collections::HashMap;
#[cfg(target_os = "linux")]
use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError};
#[cfg(target_os = "linux")]
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
/// The main window as quitting sees it.
pub(crate) trait QuittingWindow {
/// The window is a Wayland surface.
fn is_wayland(&self) -> bool;
/// Hides an X11 window.
fn hide(&self);
/// Takes a Wayland window off screen and keeps it open.
fn unmap(&self);
}
impl QuittingWindow for winit::window::Window {
#[cfg(target_os = "linux")]
fn is_wayland(&self) -> bool {
matches!(self.window_handle().map(|h| h.as_raw()), Ok(RawWindowHandle::Wayland(_)))
}
/// The BSDs have no Wayland unmap: their windows are hidden through winit.
#[cfg(not(target_os = "linux"))]
fn is_wayland(&self) -> bool {
false
}
fn hide(&self) {
self.set_visible(false);
}
#[cfg(target_os = "linux")]
fn unmap(&self) {
unmap_wayland(self);
}
#[cfg(not(target_os = "linux"))]
fn unmap(&self) {
self.set_visible(false);
}
}
/// Takes the main window and the floating windows `x11` lists off screen: a Wayland main window is
/// unmapped and any other main window hidden, and `unmap_x11` unmaps the floating windows.
pub(crate) fn off_screen<W: QuittingWindow + ?Sized>(main: Option<&W>, x11: &[u32], unmap_x11: impl FnOnce(&[u32])) {
if let Some(window) = main {
if window.is_wayland() {
window.unmap();
} else {
window.hide();
}
}
if !x11.is_empty() {
unmap_x11(x11);
}
}
/// [`off_screen`] with the running app's main window and the floating windows [`Hooks`] recorded.
/// Runs on quit, before eframe drops the app.
pub(crate) fn before_teardown(main: Option<&winit::window::Window>) {
#[cfg(target_os = "linux")]
off_screen(main, &CHILD_WINDOWS.x11(), unmap_x11);
#[cfg(not(target_os = "linux"))]
off_screen(main, &[], |_| {});
}
/// The X11 windows of floating panels and documents, by viewport, while eframe holds them.
#[cfg(target_os = "linux")]
#[derive(Default)]
pub(crate) struct ChildWindows(Mutex<HashMap<egui::ViewportId, u32>>);
#[cfg(target_os = "linux")]
impl ChildWindows {
/// eframe made `viewport`'s window, with `handle`. The main window, and windows of other window
/// systems, are not recorded; a window made again for the same viewport replaces its record.
pub(crate) fn created(&self, viewport: egui::ViewportId, handle: Option<RawWindowHandle>) {
let mut windows = self.lock();
match handle.and_then(x11_window) {
Some(id) if viewport != egui::ViewportId::ROOT => windows.insert(viewport, id),
_ => windows.remove(&viewport),
};
}
/// eframe dropped `viewport`'s window.
pub(crate) fn destroyed(&self, viewport: egui::ViewportId) {
self.lock().remove(&viewport);
}
/// The recorded windows, in id order.
pub(crate) fn x11(&self) -> Vec<u32> {
let mut ids: Vec<u32> = self.lock().values().copied().collect();
ids.sort_unstable();
ids.dedup();
ids
}
fn lock(&self) -> MutexGuard<'_, HashMap<egui::ViewportId, u32>> {
self.0.lock().unwrap_or_else(PoisonError::into_inner)
}
}
/// The X11 window id of `handle`, None for another window system.
#[cfg(target_os = "linux")]
fn x11_window(handle: RawWindowHandle) -> Option<u32> {
match handle {
RawWindowHandle::Xlib(h) => u32::try_from(h.window).ok(),
RawWindowHandle::Xcb(h) => Some(h.window.get()),
_ => None,
}
}
/// The running app's floating windows, which [`Hooks`] records.
#[cfg(target_os = "linux")]
static CHILD_WINDOWS: LazyLock<ChildWindows> = LazyLock::new(ChildWindows::default);
/// Records the windows eframe creates and drops in [`CHILD_WINDOWS`]; set as
/// [`eframe::NativeOptions::viewport_hooks`].
#[cfg(target_os = "linux")]
pub(crate) struct Hooks;
#[cfg(target_os = "linux")]
impl eframe::ViewportHooks for Hooks {
fn window_created(&self, viewport: egui::ViewportId, window: &winit::window::Window) {
CHILD_WINDOWS.created(viewport, window.window_handle().ok().map(|h| h.as_raw()));
}
fn window_destroyed(&self, viewport: egui::ViewportId) {
CHILD_WINDOWS.destroyed(viewport);
}
}
/// Unmaps the X11 `windows` through a connection of its own, and waits until the server has done so.
/// An id whose window is gone gets an error, which is ignored.
#[cfg(target_os = "linux")]
fn unmap_x11(windows: &[u32]) {
use x11rb::protocol::xproto::ConnectionExt as _;
let conn = match x11rb::connect(None) {
Ok((conn, _)) => conn,
Err(e) => {
log::warn!("couldn't reach the X server to hide the floating windows on quit: {e}");
return;
}
};
for &window in windows {
if let Ok(cookie) = conn.unmap_window(window) {
cookie.ignore_error();
}
}
// The server answers once it has handled the requests before this one.
if let Ok(cookie) = conn.get_input_focus() {
let _ = cookie.reply();
}
}
/// Attaches no buffer to `window`'s Wayland surface and commits it, which unmaps the window: the
/// compositor stops showing it, and the window and its connection stay open.
#[cfg(target_os = "linux")]
fn unmap_wayland(window: &winit::window::Window) {
let Some((conn, surface)) = super::portal_save::wayland::surface(window) else { return };
surface.attach(None, 0, 0);
surface.commit();
if let Err(e) = conn.flush() {
log::warn!("couldn't reach the compositor to hide the window on quit: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
/// A main window that records what quitting did to it.
struct Window<'a> {
wayland: bool,
done: &'a RefCell<Vec<String>>,
}
impl QuittingWindow for Window<'_> {
fn is_wayland(&self) -> bool {
self.wayland
}
fn hide(&self) {
self.done.borrow_mut().push("hide".into());
}
fn unmap(&self) {
self.done.borrow_mut().push("unmap".into());
}
}
fn quit(wayland: Option<bool>, x11: &[u32]) -> Vec<String> {
let done = RefCell::new(Vec::new());
let window = wayland.map(|wayland| Window { wayland, done: &done });
off_screen(window.as_ref(), x11, |ids| done.borrow_mut().push(format!("unmap x11 {ids:?}")));
done.into_inner()
}
/// winit cannot hide a Wayland window: quitting unmaps it, and never only asks winit to hide it.
#[test]
fn quitting_unmaps_a_wayland_main_window() {
assert_eq!(quit(Some(true), &[]), ["unmap"]);
}
/// An X11 main window (XWayland included) is hidden, and the floating panels' and documents' X11
/// windows are unmapped with it.
#[test]
fn quitting_hides_an_x11_main_window_and_its_floating_windows() {
assert_eq!(quit(Some(false), &[]), ["hide"]);
assert_eq!(quit(Some(false), &[7, 9]), ["hide", "unmap x11 [7, 9]"]);
}
/// Without a main window (none was made), the floating windows still go, and with none at all
/// nothing is done.
#[test]
fn quitting_without_a_main_window() {
assert_eq!(quit(None, &[3]), ["unmap x11 [3]"]);
assert!(quit(None, &[]).is_empty());
}
#[cfg(target_os = "linux")]
fn xlib(id: std::os::raw::c_ulong) -> Option<RawWindowHandle> {
Some(RawWindowHandle::Xlib(winit::raw_window_handle::XlibWindowHandle::new(id)))
}
#[cfg(target_os = "linux")]
fn viewport(name: &str) -> egui::ViewportId {
egui::ViewportId::from_hash_of(name)
}
/// The floating windows eframe made are recorded until it drops them; the main window, which
/// quitting reaches through winit, is not.
#[cfg(target_os = "linux")]
#[test]
fn floating_x11_windows_are_recorded_while_they_exist() {
let windows = ChildWindows::default();
windows.created(egui::ViewportId::ROOT, xlib(1));
windows.created(viewport("Layers"), xlib(9));
let xcb = winit::raw_window_handle::XcbWindowHandle::new(std::num::NonZeroU32::new(5).expect("not zero"));
windows.created(viewport("Untitled-2"), Some(RawWindowHandle::Xcb(xcb)));
windows.created(viewport("Color"), xlib(7));
assert_eq!(windows.x11(), [5, 7, 9]);
windows.destroyed(viewport("Color"));
assert_eq!(windows.x11(), [5, 9]);
// A viewport that eframe has already dropped, or never made, is no error.
windows.destroyed(viewport("Color"));
windows.destroyed(viewport("Swatches"));
assert_eq!(windows.x11(), [5, 9]);
}
/// A window made again for a viewport replaces its record, and one of another window system, or
/// one whose handle could not be read, removes it.
#[cfg(target_os = "linux")]
#[test]
fn a_window_made_again_replaces_its_record() {
let windows = ChildWindows::default();
windows.created(viewport("Layers"), xlib(9));
windows.created(viewport("Layers"), xlib(11));
assert_eq!(windows.x11(), [11]);
let surface = std::ptr::NonNull::<std::ffi::c_void>::dangling();
let wayland = winit::raw_window_handle::WaylandWindowHandle::new(surface);
windows.created(viewport("Layers"), Some(RawWindowHandle::Wayland(wayland)));
assert!(windows.x11().is_empty());
windows.created(viewport("Layers"), xlib(11));
windows.created(viewport("Layers"), None);
assert!(windows.x11().is_empty());
}
}
-18
View File
@@ -515,21 +515,3 @@ fn the_linux_desktop_entry_opens_every_readable_format() {
}
}
}
/// On Linux and the BSDs, quitting hides the main window before eframe drops the app, whose
/// clipboard handles may take up to `CLIPBOARD_PROBE_STOP_WAIT` to let go.
#[cfg(not(any(target_os = "macos", windows)))]
#[test]
fn quitting_hides_the_window_before_the_clipboards_let_go() {
struct Window(std::cell::Cell<u32>);
impl QuittingWindow for Window {
fn hide(&self) {
self.0.set(self.0.get() + 1);
}
}
let window = Window(std::cell::Cell::new(0));
hide_before_teardown(Some(&window));
assert_eq!(window.0.get(), 1, "the window was not hidden");
// Without a window (none was made), there is nothing to hide.
hide_before_teardown::<Window>(None);
}
+2 -1
View File
@@ -351,7 +351,8 @@ pub enum Direction {
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "mode")]
pub enum ArtScale {
/// Keep the art's proportions (length follows the path; width scales with it).
/// Keep the art's proportions (length follows the path; width scales with it, times the
/// stroke weight and the brush width).
Proportional,
/// Stretch the art to the path length.
#[default]
+44
View File
@@ -141,6 +141,50 @@ fn art_brush_proportional_keeps_aspect() {
assert!(near(bounds(&out).height(), 8.0, 1e-6));
}
/// Scale Proportionately art is scaled across by the stroke weight and the Width, as Stretch art
/// is, on top of the scale the path length gives it.
#[test]
fn art_brush_proportional_grows_with_weight() {
let brush = |width: f64, overlap: bool| {
let mut b = art_brush(rect_art(100.0, 4.0), ArtScale::Proportional);
if let BrushKind::Art(a) = &mut b.kind {
(a.width, a.overlap) = (width, overlap);
}
b
};
let draw = |b: &Brush, bp: &BezPath, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, bp, st);
// 3 pt stroke → 3× wider than at 1 pt (8 pt); the length still follows the path.
let b = bounds(&draw(&brush(100.0, false), &line(0.0, 0.0, 200.0, 0.0), &stroke(3.0)));
assert!(near(b.height(), 24.0, 1e-6) && near(b.x0, 0.0, 1e-6) && near(b.x1, 200.0, 1e-6), "{b:?}");
// The Width (50 %) applies on top of the stroke weight.
let b = bounds(&draw(&brush(50.0, false), &line(0.0, 0.0, 200.0, 0.0), &stroke(3.0)));
assert!(near(b.height(), 12.0, 1e-6), "{b:?}");
// A path twice as long at twice the weight (as Scale Strokes & Effects at 200 % leaves it):
// 2× from the length and 2× from the weight.
let b = bounds(&draw(&brush(100.0, false), &line(0.0, 0.0, 400.0, 0.0), &stroke(2.0)));
assert!(near(b.height(), 32.0, 1e-6), "{b:?}");
// With Overlap, each run between corners is scaled by its own length and by the weight: the
// 100 pt run 4 pt × 3 across, the 50 pt run 2 pt × 3.
let mut bent = BezPath::new();
bent.move_to((0.0, 0.0));
bent.line_to((100.0, 0.0));
bent.line_to((100.0, 50.0));
let out = draw(&brush(100.0, true), &bent, &stroke(3.0));
assert_eq!(out.len(), 2);
let (r0, r1) = (out[0].geometric_bounds().unwrap(), out[1].geometric_bounds().unwrap());
assert!(near(r0.height(), 12.0, 1e-6) && near(r1.width(), 6.0, 1e-6), "{r0:?} {r1:?}");
// A width that follows the pen: 50 % at the lightest pressure, 300 % at the heaviest, times
// the 2 pt weight (4 pt art on a 100 pt path: 4 pt across at 100 %, 1 pt either side).
let mut pen = brush(50.0, false);
if let BrushKind::Art(a) = &mut pen.kind {
(a.width_mode, a.width_max) = (Some(Variation::Pressure), Some(300.0));
}
let ramp = StrokeLayer { width: 2.0, ..pressed(vec![(0.0, 0.0), (1.0, 1.0)]) };
let out = draw(&pen, &line(0.0, 0.0, 100.0, 0.0), &ramp);
assert!(near(half_width(&out, 0.0, 1.0), 2.0, 0.1), "{}", half_width(&out, 0.0, 1.0));
assert!(near(half_width(&out, 99.0, 100.0), 12.0, 0.2), "{}", half_width(&out, 99.0, 100.0));
}
#[test]
fn art_brush_between_guides_keeps_ends() {
// Head (last 20 % of a 100 pt art) keeps its 20 pt length on a 300 pt path.
+5 -16
View File
@@ -465,17 +465,8 @@ fn art_runs(t: &Track) -> Vec<(f64, f64)> {
bounds.windows(2).map(|w| (w[0], w[1])).collect()
}
/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1, and
/// `width` the width (%) there.
fn art_on_run(
t: &Track,
(s0, s1): (f64, f64),
a: &ArtBrush,
(art, b): (&Node, Rect),
cross: f64,
width: f64,
widen: &dyn Fn(f64) -> f64,
) -> Option<Node> {
/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1.
fn art_on_run(t: &Track, (s0, s1): (f64, f64), a: &ArtBrush, (art, b): (&Node, Rect), cross: f64, widen: &dyn Fn(f64) -> f64) -> Option<Node> {
let l = s1 - s0;
if l <= 1e-9 {
return None;
@@ -483,10 +474,8 @@ fn art_on_run(
let w = b.width();
let (along, cross) = match a.scale {
ArtScale::Stretch => (AlongMap::linear(w, s0, s1), cross),
ArtScale::Proportional => {
let k = l / w;
(AlongMap::linear(w, s0, s1), k * width / 100.0)
}
// Scaled across as much as along, times the stroke weight and the width, as the other modes are.
ArtScale::Proportional => (AlongMap::linear(w, s0, s1), l / w * cross),
ArtScale::BetweenGuides { start, end } => {
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
let (g0, g1) = (g0.min(g1), g0.max(g1));
@@ -528,7 +517,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, stroke: &StrokeLayer)
};
let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, l)] };
for run in runs {
if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, width, &widen) {
if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, &widen) {
out.push(n);
}
}
+43
View File
@@ -547,6 +547,49 @@ impl Gradient {
None => (Color::BLACK, 1.0),
}
}
/// Which of its stops color some part of it that draws, by the stretches [`Self::sample`]
/// mixes in, for stops in any order: the pair of stop `k` and stop `k + 1` covers the offsets
/// above every offset of stops `0..=k` up to stop `k + 1`'s, and both its stops count when that
/// stretch has positive width and either stop is above opacity 0. The first stop also counts
/// when it is above opacity 0, since it colors the offsets up to its own, and so does the last
/// stop, since it colors the offsets above every stop's. Any other stop, opaque or not, counts
/// only through a pair: a stop out of order whose pairs have no width (another stop before it
/// reaches past both ends) colors nothing. A freeform gradient draws from its points, so each of
/// its stops counts while any stop is above opacity 0.
pub fn drawn_stops(&self) -> Vec<bool> {
self.stops_with_color(|s| s.opacity > 0.0)
}
/// [`Self::drawn_stops`] with every stop taken as opaque, for output that leaves stop opacity
/// out: the stops that color a stretch of positive width, and the first and last stops.
pub fn reached_stops(&self) -> Vec<bool> {
self.stops_with_color(|_| true)
}
/// [`Self::drawn_stops`] with `shows` telling whether a stop's opacity shows its color.
fn stops_with_color(&self, shows: impl Fn(&GradientStop) -> bool) -> Vec<bool> {
let stops = &self.stops;
if self.kind == GradientKind::Freeform {
let any = stops.iter().any(&shows);
return vec![any; stops.len()];
}
let mut drawn = vec![false; stops.len()];
if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) {
*d = shows(first);
}
if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) {
*d |= shows(last);
}
// `f32::max` skips a NaN offset, which the sampler never picks either.
let mut reach = f32::NEG_INFINITY;
for k in 0..stops.len().saturating_sub(1) {
reach = reach.max(stops[k].offset);
let (a, b) = (&stops[k], &stops[k + 1]);
if b.offset > reach && (shows(a) || shows(b)) {
drawn[k] = true;
drawn[k + 1] = true;
}
}
drawn
}
/// [`Self::sample_with`] at each of `ts`, which must not decrease. Each search starts where
/// the one before it stopped, so the work is the samples plus the stops, not their product.
pub fn sample_increasing_with(&self, ts: impl IntoIterator<Item = f32>, mix: impl Fn(&Color, &Color, f32) -> Color) -> Vec<(Color, f32)> {
+63
View File
@@ -66,6 +66,69 @@ fn stop_reach_matches_scanning_the_stops() {
}
}
/// A gradient's stops color what draws where they color a stretch that draws, for stops in any
/// order: the sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it,
/// so an out-of-order stop's pair can have no width.
#[test]
fn drawn_stops_follow_the_sampled_stretches() {
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) };
let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops);
// In order: the clear stop next to the opaque one, not the one after it.
assert_eq!(g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![true, true, false]);
// Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with
// the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8.
assert_eq!(g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)]).drawn_stops(), vec![false, false, true, true]);
// An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the
// pairs next to the stop at 0.3 have no width and it colors nothing.
let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]);
assert_eq!(out_of_order(0.0).drawn_stops(), vec![false; 5]);
// With the stop at 0.8 opaque, the pairs next to it with width are drawn.
assert_eq!(out_of_order(1.0).drawn_stops(), vec![true, true, false, false, false]);
// The last stop colors the offsets above every stop's, even when it is out of order.
assert_eq!(g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)]).drawn_stops(), vec![false, false, true]);
// The first stop colors the offsets up to its own.
assert_eq!(g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![true, false, false]);
// One stop, every stop clear, and no stops at all.
assert_eq!(g(vec![stop(0.5, 1.0)]).drawn_stops(), vec![true]);
assert_eq!(g(vec![stop(0.0, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![false, false]);
assert!(g(vec![]).drawn_stops().is_empty());
// A freeform gradient draws from its points: its stops count while any is above opacity 0.
let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]);
free.kind = GradientKind::Freeform;
assert_eq!(free.drawn_stops(), vec![true, true, true]);
free.stops.iter_mut().for_each(|s| s.opacity = 0.0);
assert_eq!(free.drawn_stops(), vec![false; 3]);
}
/// Taken as opaque, every stop that colors a stretch of positive width counts, and the first and
/// last stops, whatever their opacity; an out-of-order stop whose pairs have no width does not.
#[test]
fn reached_stops_leave_out_only_stops_without_width() {
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) };
let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops);
// Clear stops in order color their stretches once opaque.
assert_eq!(g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]).reached_stops(), vec![true; 3]);
assert_eq!(g(vec![stop(0.0, 0.0), stop(1.0, 0.0)]).reached_stops(), vec![true, true]);
// The stop at 0.3 sits inside the stretch the stops at 0 and 0.8 cover.
let out_of_order = g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]);
assert_eq!(out_of_order.reached_stops(), vec![true, true, false, true, true]);
// A stop at the offset of the stop before it: its pair has no width, the next one does.
assert_eq!(g(vec![stop(0.0, 1.0), stop(0.0, 1.0), stop(1.0, 1.0)]).reached_stops(), vec![true, true, true]);
assert_eq!(
g(vec![stop(0.0, 1.0), stop(0.6, 1.0), stop(0.6, 1.0), stop(0.2, 1.0), stop(1.0, 1.0)]).reached_stops(),
vec![true, true, false, true, true]
);
assert!(g(vec![]).reached_stops().is_empty());
// Every pair the sampler picks between the ends has both its stops counted.
let stops: Vec<GradientStop> = [0.1, 0.4, 0.4, 0.2, 0.9, 0.5].iter().map(|&o| stop(o, 1.0)).collect();
let g = g(stops);
let (reached, reach) = (g.reached_stops(), StopReach::new(&g.stops));
for t in (5..=95).map(|k| k as f32 / 100.0).filter(|&t| t > 0.1 && t <= 0.9) {
let k = reach.0.partition_point(|r| *r < t);
assert!(reached[k] && reached[k + 1], "pair {k} at {t}: {reached:?}");
}
}
/// Repeated offsets turn off the direct midpoint mix, and the midpoints still come out as
/// `sample` gives them, with work that stays near linear in the stops (thousands of stops at one
/// offset used to scan them all for each midpoint).
+10
View File
@@ -271,6 +271,16 @@ impl Appearance {
_ => None,
})
}
/// Does any fill draw: one that is shown, has paint and has an opacity above 0, wherever it
/// sits in the stack?
pub fn has_drawn_fill(&self) -> bool {
self.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() && f.opacity > 0.0))
}
/// Does any stroke draw: one that is shown, has paint, a weight above 0 and an opacity above
/// 0, wherever it sits in the stack?
pub fn has_drawn_stroke(&self) -> bool {
self.painted_strokes().any(|s| s.width > 0.0 && s.opacity > 0.0)
}
/// Largest distance the painted area can extend beyond any geometry (see
/// [`StrokeLayer::reach`]); paths measure their own with [`Self::stroked_bounds`].
pub fn outset(&self) -> f64 {
+22
View File
@@ -79,6 +79,28 @@ fn basic_means_one_plain_fill_and_stroke() {
assert!(!e.is_basic(), "an effect");
}
#[test]
fn drawn_fills_and_strokes_count_every_row() {
let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p));
let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w));
let black = || Paint::solid(Color::BLACK);
assert!(!Appearance::default().has_drawn_fill() && !Appearance::default().has_drawn_stroke());
// A painted lower row draws under an unpainted top row.
let a = Appearance { items: vec![fill(black()), stroke(black(), 2.0), fill(Paint::None), stroke(Paint::None, 1.0)], ..Appearance::default() };
assert!(a.has_drawn_fill() && a.has_drawn_stroke());
// A hidden painted row, a zero opacity or a zero weight draws nothing.
let mut b = Appearance { items: vec![fill(black()), stroke(black(), 2.0)], ..Appearance::default() };
b.fill_mut().unwrap().visible = false;
b.stroke_mut().unwrap().visible = false;
assert!(!b.has_drawn_fill() && !b.has_drawn_stroke());
let mut c = Appearance::default_art();
c.fill_mut().unwrap().opacity = 0.0;
c.stroke_mut().unwrap().opacity = 0.0;
assert!(!c.has_drawn_fill() && !c.has_drawn_stroke());
let d = Appearance::basic(Paint::None, black(), 0.0);
assert!(!d.has_drawn_fill() && !d.has_drawn_stroke());
}
#[test]
fn set_creates_missing() {
let mut a = Appearance::default();
+109 -16
View File
@@ -515,9 +515,10 @@ pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi:
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
/// and grayscale with extra samples, of which the first is the alpha when the file says so (other
/// channels are left out). Associated (premultiplied) alpha is divided back out. The samples may be
/// stored together or one plane per channel. `None` for other images and ones larger than
/// [`MAX_CMYK_PIXELS`].
/// channels are left out). Associated (premultiplied) alpha is divided back out, and a pixel with
/// no associated alpha has no color. 16-bit samples are converted at 16 bits, and only the result
/// is rounded to 8 bits. The samples may be stored together or one plane per channel. `None` for
/// other images and ones larger than [`MAX_CMYK_PIXELS`].
pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
use tiff::tags::Tag;
let (kind, mut d) = tiff(bytes)?;
@@ -535,20 +536,33 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
_ => return None,
};
let n = usize::try_from(pixels(w, h)?).ok()?;
let data = read_samples(&mut d, n, samples)?;
let mut out = Vec::with_capacity(n * 4);
for p in data.chunks_exact(samples) {
let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX };
let a = f32::from(opacity) / 255.0;
let level = |v: u8| {
let v = f32::from(v) / 255.0;
if extra == Some(1) && a > 0.0 { (v / a).min(1.0) } else { v }
let data = read_samples_at_depth(&mut d, n, samples)?;
// Sample `i` of the image, 0..1.
let level_at = |i: usize| match &data {
Samples::Eight(v) => v.get(i).map(|&s| f32::from(s) / 255.0),
Samples::Sixteen(v) => v.get(i).map(|&s| f32::from(s) / 65535.0),
};
// Sample `i` of the image as 8 bits, a 16-bit sample rounded to the nearest.
let byte_at = |i: usize| match &data {
Samples::Eight(v) => v.get(i).copied(),
Samples::Sixteen(v) => v.get(i).map(|&s| u8::try_from((u32::from(s) + 128) / 257).unwrap_or(u8::MAX)),
};
let mut out = Vec::with_capacity(n.checked_mul(4)?);
for first in (0..n).map(|p| p * samples) {
let (opacity, a) = if alpha { (byte_at(first + colour).unwrap_or(u8::MAX), level_at(first + colour).unwrap_or(1.0)) } else { (u8::MAX, 1.0) };
let level = |s: usize| {
let v = level_at(first + s).unwrap_or(0.0);
match extra {
Some(1) if a > 0.0 => (v / a).min(1.0),
// No associated alpha: the color is zero (TIFF 6.0, Associated Alpha Handling).
Some(1) if alpha => 0.0,
_ => v,
}
};
let [r, g, b] = match *p {
[c, m, y, k, ..] if colour == 4 => rgb([level(c), level(m), level(y), level(k)]),
[v, ..] if photometric == Some(0) => [1.0 - level(v); 3],
[v, ..] => [level(v); 3],
[] => continue,
let [r, g, b] = match colour {
4 => rgb([level(0), level(1), level(2), level(3)]),
_ if photometric == Some(0) => [1.0 - level(0); 3],
_ => [level(0); 3],
};
out.extend([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
out.push(opacity);
@@ -767,6 +781,85 @@ mod tests {
assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque");
}
/// Five 16-bit samples a pixel: the four inks and one extra sample, which the file's
/// `ExtraSamples` names.
struct Cmyka16;
impl tiff::encoder::colortype::ColorType for Cmyka16 {
type Inner = u16;
const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::CMYK;
const BITS_PER_SAMPLE: &'static [u16] = &[16; 5];
const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 5];
fn horizontal_predict(row: &[u16], result: &mut Vec<u16>) {
result.extend_from_slice(row);
}
}
/// Two 16-bit samples a pixel: gray and one extra sample.
struct GrayExtra16;
impl tiff::encoder::colortype::ColorType for GrayExtra16 {
type Inner = u16;
const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::BlackIsZero;
const BITS_PER_SAMPLE: &'static [u16] = &[16, 16];
const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 2];
fn horizontal_predict(row: &[u16], result: &mut Vec<u16>) {
result.extend_from_slice(row);
}
}
/// A one-row TIFF of `C`'s pixels `data`, with the ExtraSamples value `extra`.
fn extra_tiff<C: tiff::encoder::colortype::ColorType<Inner = u16>>(extra: u16, data: &[u16]) -> Vec<u8> {
let samples = C::BITS_PER_SAMPLE.len();
let mut file = Cursor::new(Vec::new());
let mut enc = tiff::encoder::TiffEncoder::new(&mut file).unwrap();
let mut img = enc.new_image::<C>(u32::try_from(data.len() / samples).unwrap(), 1).unwrap();
img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[extra][..]).unwrap();
img.write_data(data).unwrap();
file.into_inner()
}
/// 16-bit CMYK with alpha converts its 16-bit inks and alpha, and rounds only the result to 8
/// bits; the high byte of each sample alone would give other pixels.
#[test]
fn a_16_bit_cmyk_tiff_with_alpha_converts_at_16_bits() {
let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)];
let read = |extra: u16, data: &[u16]| extra_samples_tiff_rgba(&extra_tiff::<Cmyka16>(extra, data), naive).expect("CMYKA reads").into_raw();
// Unassociated: 129 of cyan, opaque; no ink at an alpha of 129; no ink at an alpha of 386.
let straight = [129, 0, 0, 0, 65535, 0, 0, 0, 0, 129, 0, 0, 0, 0, 386];
assert_eq!(read(2, &straight), [254, 255, 255, 255, 255, 255, 255, 1, 255, 255, 255, 2]);
// Associated: cyan 128 at an alpha of 256 (half cyan); K 255 at an alpha of 511 (about half K).
let premultiplied = [128, 0, 0, 0, 256, 0, 0, 0, 255, 511];
assert_eq!(read(1, &premultiplied), [128, 255, 255, 1, 128, 128, 128, 2], "divided by the 16-bit alpha");
}
/// 16-bit gray with alpha converts at 16 bits as well.
#[test]
fn a_16_bit_grayscale_tiff_with_alpha_converts_at_16_bits() {
let read = |extra: u16| {
// A gray of 129, opaque, then 128 at an alpha of 256.
let bytes = extra_tiff::<GrayExtra16>(extra, &[129, 65535, 128, 256]);
extra_samples_tiff_rgba(&bytes, |_| [0.0; 3]).expect("gray with alpha reads").into_raw()
};
assert_eq!(read(2), [1, 1, 1, 255, 0, 0, 0, 1], "unassociated");
assert_eq!(read(1), [1, 1, 1, 255, 128, 128, 128, 1], "associated, divided by the 16-bit alpha");
}
/// With associated alpha, a pixel whose alpha is zero has no color (TIFF 6.0, Associated
/// Alpha Handling), whatever its color samples hold.
#[test]
fn a_pixel_with_no_associated_alpha_has_no_color() {
use tiff::encoder::{TiffEncoder, colortype::CMYKA8};
let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)];
let mut file = Cursor::new(Vec::new());
let mut enc = TiffEncoder::new(&mut file).unwrap();
let mut img = enc.new_image::<CMYKA8>(1, 1).unwrap();
img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap();
img.write_data(&[255, 0, 0, 0, 0]).unwrap();
let cmyk = extra_samples_tiff_rgba(&file.into_inner(), naive).expect("CMYKA reads");
assert_eq!(cmyk.into_raw(), [255, 255, 255, 0], "no ink");
let gray = extra_samples_tiff_rgba(&extra_tiff::<GrayExtra16>(1, &[50_000, 0]), |_| [0.0; 3]).expect("gray reads");
assert_eq!(gray.into_raw(), [0, 0, 0, 0], "a gray of zero");
}
/// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel,
/// times 2 planes.
#[test]
+1 -1
View File
@@ -18,7 +18,7 @@ mod wrap;
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
use char_style::yes;
pub use char_style::{
CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES,
CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_TSUME, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES,
WARICHU_SCALE, WarichuAlign,
};
pub use composite::{
+15 -1
View File
@@ -44,6 +44,14 @@ pub struct CharStyle {
/// Insert Aki Right: space added after the character in reading order, as [`Self::aki_left`].
#[serde(default, rename = "akiRight", skip_serializing_if = "crate::skip::is_default")]
pub aki_right: f64,
/// Tsume (Character panel, East Asian options): the percentage of the blank space on each side
/// of the character's ink, inside its own width, that is taken away, 0 to [`MAX_TSUME`]. 50
/// halves that space and 100 sets the ink edge to edge; the glyph itself is not scaled. A
/// cluster takes the value of its first character. Tracking, kerning and Insert Aki are added
/// after it. Horizontal type only.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub tsume: f64,
/// Use glyph shapes for automatic pair spacing; false uses font metrics.
#[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")]
pub optical_kerning: bool,
@@ -440,6 +448,9 @@ fn hundred() -> f64 {
/// The largest Insert Aki Left or Right ([`CharStyle::aki_left`]), in 1/1000 em: one em.
pub const MAX_AKI: f64 = 1000.0;
/// The largest Tsume ([`CharStyle::tsume`]), in percent: all the blank space beside the ink.
pub const MAX_TSUME: f64 = 100.0;
/// The number of lines a warichu block may be stacked on ([`CharStyle::warichu_lines`]).
pub const WARICHU_LINES: std::ops::RangeInclusive<u8> = 1..=6;
@@ -534,6 +545,7 @@ impl Default for CharStyle {
kerning_before: 0.0,
aki_left: 0.0,
aki_right: 0.0,
tsume: 0.0,
optical_kerning: false,
baseline_shift: 0.0,
h_scale: 100.0,
@@ -588,7 +600,8 @@ impl CharStyle {
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
/// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right
/// offsets are kept as the baseline shift is. Insert Aki Left and Right are kept from 0 to
/// [`MAX_AKI`], and one that is not a number becomes 0. The Warichu Settings Lines and Scale
/// [`MAX_AKI`], and one that is not a number becomes 0; Tsume is kept from 0 to [`MAX_TSUME`] the
/// same way. The Warichu Settings Lines and Scale
/// are kept to [`WARICHU_LINES`] and [`WARICHU_SCALE`]; a gap or scale that is not a number becomes its default. The gap is kept to 0 to
/// [`crate::MAX_COORD`], as the baseline shift is, since a scaled type object holds it in its
/// own units.
@@ -606,6 +619,7 @@ impl CharStyle {
let aki = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) };
self.aki_left = aki(self.aki_left);
self.aki_right = aki(self.aki_right);
self.tsume = if self.tsume.is_nan() { 0.0 } else { self.tsume.clamp(0.0, MAX_TSUME) };
self.warichu_lines = self.warichu_lines.clamp(*WARICHU_LINES.start(), *WARICHU_LINES.end());
self.warichu_gap = if self.warichu_gap.is_nan() { default.warichu_gap } else { self.warichu_gap.clamp(0.0, crate::MAX_COORD) };
self.warichu_scale =
+2
View File
@@ -507,6 +507,7 @@ impl crate::Document {
st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right);
st.aki_left = num("akiLeft").unwrap_or(st.aki_left);
st.aki_right = num("akiRight").unwrap_or(st.aki_right);
st.tsume = num("tsume").unwrap_or(st.tsume);
st.warichu_gap = num("warichuGap").unwrap_or(st.warichu_gap);
st.warichu_scale = num("warichuScale").unwrap_or(st.warichu_scale);
st.keep_dimensions_in_range();
@@ -532,6 +533,7 @@ impl crate::Document {
("tcyLeftRight", Some(st.tcy_left_right)),
("akiLeft", Some(st.aki_left)),
("akiRight", Some(st.aki_right)),
("tsume", Some(st.tsume)),
("leading", st.leading),
];
for (k, v) in kept {
+2 -1
View File
@@ -357,7 +357,7 @@ impl ParaList {
/// The style the marker is set in: `first` (the style of the paragraph's first character)
/// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning,
/// Insert Aki, tate-chu-yoko, warichu, underline and strikethrough.
/// Insert Aki, Tsume, tate-chu-yoko, warichu, underline and strikethrough.
pub fn marker_style(first: &CharStyle) -> CharStyle {
CharStyle {
baseline_shift: 0.0,
@@ -368,6 +368,7 @@ impl ParaList {
kerning_before: 0.0,
aki_left: 0.0,
aki_right: 0.0,
tsume: 0.0,
optical_kerning: first.optical_kerning,
tate_chu_yoko: false,
warichu: false,
+28
View File
@@ -129,6 +129,34 @@ fn insert_aki_is_saved_only_when_set() {
assert_eq!((d.char_styles[0].attrs["akiLeft"].as_f64(), d.char_styles[0].attrs["akiRight"].as_f64()), (Some(MAX_AKI), Some(0.0)));
}
/// Tsume is 0 by default and saved only when not 0; styles written before it read as 0, and a
/// value out of range from a file, in a run or a character style, is kept from 0 to 100.
#[test]
fn tsume_is_saved_only_when_set_and_kept_in_range() {
let st = CharStyle::default();
assert_eq!(st.tsume, 0.0);
let saved = serde_json::to_value(&st).unwrap();
assert!(saved.get("tsume").is_none());
let read = serde_json::from_value::<CharStyle>(saved).unwrap();
assert_eq!(read.tsume, 0.0);
let set = CharStyle { tsume: 37.5, ..CharStyle::default() };
let saved = serde_json::to_value(&set).unwrap();
assert_eq!(saved.get("tsume").and_then(serde_json::Value::as_f64), Some(37.5));
assert_eq!(serde_json::from_value::<CharStyle>(saved).unwrap(), set);
for (stored, kept) in [(-5.0, 0.0), (250.0, MAX_TSUME), (f64::NAN, 0.0), (f64::INFINITY, MAX_TSUME), (50.0, 50.0)] {
let mut damaged = CharStyle { tsume: stored, ..CharStyle::default() };
damaged.keep_dimensions_in_range();
assert_eq!(damaged.tsume, kept, "{stored}");
}
let mut d = crate::Document::new(100.0, 100.0);
for tsume in [500, -1] {
let attrs = serde_json::json!({ "tsume": tsume });
d.char_styles.push(TextStyleDef { name: format!("Tight {tsume}"), attrs: attrs.as_object().cloned().unwrap_or_default() });
}
d.keep_type_values_in_range();
assert_eq!((d.char_styles[0].attrs["tsume"].as_f64(), d.char_styles[1].attrs["tsume"].as_f64()), (Some(MAX_TSUME), Some(0.0)));
}
/// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its
/// script's block; every system saves under its id, which reads back, and other names are refused.
#[test]
+24 -9
View File
@@ -26,20 +26,20 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2};
use crate::distort::{transform_move, transform_offsets};
use crate::group::{has_own_paint, paints};
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
use crate::reshape::{as_art, clipping_text_outline, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
use crate::{
GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape,
};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
pub(crate) fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
match item {
AppearanceItem::Fill(f) => &f.effects,
AppearanceItem::Stroke(s) => &s.effects,
}
}
fn clear_item_effects(item: &mut AppearanceItem) {
pub(crate) fn clear_item_effects(item: &mut AppearanceItem) {
match item {
AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects),
AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects),
@@ -74,7 +74,7 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
/// `path` with the geometry effects of `effects` applied, and how gradients follow them
/// ([`crate::Carry`]).
fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) {
pub(crate) fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) {
if !has_geometry(effects) || path.is_empty() {
return (path.clone(), crate::Carry::default());
}
@@ -86,7 +86,7 @@ fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext
/// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient
/// moved along with them and the Transform effects the geometry pass left out hidden
/// ([`crate::Carry::hide_skipped`]).
fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) {
pub(crate) fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) {
let (ig, carry) = apply(item_effects(item), g, &ctx.item(item));
let mut ap = Appearance { items: vec![carried.clone()], ..Default::default() };
if carry.moves(&ap) {
@@ -202,7 +202,22 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
return bake_leaf(d, n);
}
let mut m = n.clone();
let baked: Vec<Arc<Node>> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect();
// A clipping path takes the geometry effects in it as the canvas clips by it, those of a
// compound path's paths before the compound path's own ([`crate::clip::with_clip_effects_applied`]);
// the pieces it is made of then get ids of their own.
let clip = usize::from(n.clips());
let baked: Vec<Arc<Node>> = ch
.iter()
.enumerate()
.map(|(i, c)| match (i < clip).then(|| crate::clip::with_clip_effects_applied(c)).flatten() {
Some(a) => {
let mut a = bake_node(d, &a).unwrap_or(a);
fresh_ids(d, &mut a, &mut std::collections::HashSet::new());
Arc::new(a)
}
None => bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone()),
})
.collect();
if let Some(ch) = m.children_mut() {
*ch = baked;
}
@@ -407,7 +422,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
last => Some(Node::group(c.id, copies.into_iter().chain(last).map(Arc::new).collect())),
}
}
None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
None if clipping => clipping_text_outline(c),
None => as_art(Some(d), c, None),
};
if let Some(o) = art {
@@ -469,7 +484,7 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea
/// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of
/// its effects, and `n` without those effects, its gradients moved along with them.
fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
pub(crate) fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
let (base, rule) = geometry(n)?;
let ctx = GeomContext::of(n);
let mut m = n.clone();
+3 -3
View File
@@ -793,7 +793,7 @@ fn type_outlined_next_to_type_that_stays_type_keeps_its_look() {
}
#[test]
fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
fn type_in_a_clipping_group_with_a_fill_of_its_own_keeps_its_effect() {
if skip_without_craft_fonts() {
return;
}
@@ -808,9 +808,9 @@ fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
g.children_mut().unwrap().push(Arc::new(clip));
let d = bake_document(&doc_with(g)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
// The type clips by its plain outlines, as the canvas clips by them: its own effect is left out.
// The type clips by its outlines moved by its own effect, as the canvas clips by them.
let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap();
assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}");
assert!(b.y0 > 330.0 && b.y1 < 370.0, "{b:?}");
}
#[test]
+117 -2
View File
@@ -7,9 +7,124 @@ use vectorcraft_geom::{BezPath, FillRule, PathData};
/// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their
/// holes, even-odd paths their rule, text clips by its glyph outlines and a group by the union of
/// its members. `None` when there is nothing to clip by (the clipped art is then hidden).
/// its members. The live geometry effects of the clipping path (and of the members it clips by)
/// reshape the region, as they reshape what it paints, and so do the geometry effects of a path's
/// fills and strokes: the region is then the union of the paths they give, as an export bakes them.
/// `None` when there is nothing to clip by (the clipped art is then hidden).
pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> {
clip.clip_outline(Some(&outline_text), &unite)
match clip_effects_applied(clip, true) {
Some(m) => m.clip_outline(Some(&outline_text), &unite),
None => clip.clip_outline(Some(&outline_text), &unite),
}
}
/// `n`, the clipping path of a clip group, with the live geometry effects of everything it clips by
/// applied, innermost first, and removed: a path or compound path with effects becomes a path of
/// its reshaped geometry that keeps its fills, strokes and other effects (its gradients follow the
/// effects, as an export bakes it), the paths of a compound path and the visible members of a group
/// are reshaped by their own effects before the effects around them, a group's or layer's own
/// effects reshape its members as an export evaluates them, and other objects with effects (type)
/// become a path of their reshaped region. A path or compound path whose fills or strokes have
/// geometry effects becomes a group of one path per fill and stroke, reshaped by the path's effects
/// and then by that fill's or stroke's, as an export bakes it; [`clip_outline`] clips by their
/// union. A clipping path that paints ([`Node::clip_paint`], which a group does not) keeps the
/// geometry effects of its own fills and strokes live instead. `None` when nothing in it has a
/// geometry effect. Effects on the clip group or around it then reshape the result, after its own.
pub(crate) fn with_clip_effects_applied(n: &Node) -> Option<Node> {
let paint = n.clip_paint();
clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none())
}
/// [`with_clip_effects_applied`]; with `items` unset (a clipping path that paints, and the paths of
/// a compound path, which draw with the compound path's fills and strokes), the geometry effects of
/// `n`'s own fills and strokes are left out.
fn clip_effects_applied(n: &Node, items: bool) -> Option<Node> {
let mut m: Option<Node> = None;
let members: &[std::sync::Arc<Node>] = match &n.kind {
NodeKind::Group { children, clip: true } => children.get(..1).unwrap_or_default(),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => children,
_ => &[],
};
// A group or layer clips by its visible members after its shaper; a compound path by all of
// its paths, and a nested clip group by its clipping path, shown or hidden
// ([`Node::clip_shapes`]).
let all = matches!(n.kind, NodeKind::Compound { .. } | NodeKind::Group { clip: true, .. });
let skip = usize::from(matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }) && n.extras().shaper.is_some());
// The paths of a compound path draw with the compound path's fills and strokes, and stay paths.
let compound = matches!(n.kind, NodeKind::Compound { .. });
for (i, c) in members.iter().enumerate().skip(skip).filter(|(_, c)| all || c.visible) {
if let Some(r) = clip_effects_applied(c, items && !compound)
&& let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i))
{
*slot = std::sync::Arc::new(r);
}
}
let items = items
&& matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
&& n.appearance.items.iter().any(|i| crate::has_geometry(crate::bake::item_effects(i)));
if items && let Some((g, rule, ctx, mut p)) = crate::bake::leaf_base(m.as_ref().unwrap_or(n)) {
let children = n
.appearance
.items
.iter()
.zip(&p.appearance.items)
.map(|(item, carried)| {
let (ig, mut it) = crate::bake::apply_item(item, carried, &g, &ctx);
crate::bake::clear_item_effects(&mut it);
let mut piece = Node::path(n.id, ig, Appearance { items: vec![it], ..Default::default() });
if let NodeKind::Path { rule: r, .. } = &mut piece.kind {
*r = rule;
}
std::sync::Arc::new(piece)
})
.collect();
// The path's transparency and raster effects stay on the group of its pieces.
p.appearance.items.clear();
p.kind = NodeKind::Group { children, clip: false };
return Some(p);
}
if !crate::has_geometry(&n.appearance.effects) {
return m;
}
let cur = m.as_ref().unwrap_or(n);
// A group's or layer's effects reshape each of its members (a clip group's its clipping
// path), as an export evaluates them; otherwise they reshape its region, below.
if matches!(cur.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
&& let Some(e) = crate::group::evaluate_container(None, cur)
{
return Some(e);
}
// A path or compound path, as an export bakes it.
if let Some((g, rule, _, mut p)) = crate::bake::leaf_base(cur) {
let clipping = match &cur.kind {
NodeKind::Path { clipping, .. } => *clipping,
NodeKind::Compound { children, .. } => children.iter().any(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. })),
_ => false,
};
p.kind = Node::path(n.id, g, Appearance::default()).kind;
if let NodeKind::Path { rule: r, clipping: c, .. } = &mut p.kind {
(*r, *c) = (rule, clipping);
}
return Some(p);
}
// Type, as its painted outlines that its effects reshape as an export evaluates them.
if matches!(cur.kind, NodeKind::Text(_))
&& let Some(mut o) = crate::outline_text(cur)
{
o.visible = n.visible;
o.appearance.effects = n.appearance.effects.clone();
if let Some(e) = crate::group::evaluate_container(None, &o) {
return Some(e);
}
}
let (bp, rule) = cur.clip_outline(Some(&outline_text), &unite)?;
let (g, _) = crate::bake::apply(&n.appearance.effects, &PathData::from_bezpath(&bp), &crate::GeomContext::of(n));
let mut p = Node::path(n.id, g, Appearance::default());
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
*r = rule;
}
p.visible = n.visible;
Some(p)
}
/// The knockout shape of a piece of a mesh-warped image ([`Node::knockout_outline`]) painted opaque
+22 -7
View File
@@ -661,8 +661,9 @@ pub fn has_container_appearance(n: &Node) -> bool {
/// The regions a group's or layer's own fills and strokes paint, in document space, each with its
/// fill rule: every visible member path and compound path, type as its glyph outlines and live
/// objects evaluated; a compound shape as its area, with the symbol instances among its members
/// as their symbols' art from `doc`; the members of a clip group or clipping layer without its
/// clipping path. Images, other symbol instances and guides add nothing. A Repeat counts the
/// as their symbols' art from `doc`; a clip group or clipping layer (`n` itself or one among its
/// members) as the region its clipping path clips to ([`crate::clip::clip_outline`]), so members
/// it hides add nothing. Images, other symbol instances and guides add nothing. A Repeat counts the
/// Repeats in the art of `doc`'s symbols that its instances show toward its instance budget.
pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> {
fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) {
@@ -690,8 +691,14 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul
}
}
fn members(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) {
let clip = usize::from(n.clips());
for c in n.children().into_iter().flatten().skip(clip) {
// A clip group shows its members only inside its clipping path: its outline is that
// region, not the clipped members' own shapes, which may reach far outside it (#7289).
if n.clips() {
let clip = n.children().and_then(|ch| ch.first());
out.extend(clip.and_then(|c| crate::clip::clip_outline(c)).map(|(bp, rule)| (PathData::from_bezpath(&bp), rule)));
return;
}
for c in n.children().into_iter().flatten() {
push(doc, c, out);
}
}
@@ -838,9 +845,10 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
/// Replace each visible member of `n` with geometry or Pathfinder effects of its own, at every
/// depth of its groups and layers, by the art it draws ([`evaluate_container`],
/// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members
/// draw. A clip group's clipping path stays as it is, and so do images, symbol instances and the
/// other objects that `own_effects_art` leaves alone, and a group or layer with a 3D effect with
/// everything in it. Calls nested in one another, through the
/// draw. A clip group's clipping path only takes the geometry effects in it into its geometry
/// ([`crate::clip::with_clip_effects_applied`]). Images, symbol instances and the other objects
/// that `own_effects_art` leaves alone stay as they are, and so does a group or layer with a 3D
/// effect with everything in it. Calls nested in one another, through the
/// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members
/// included), stop at [`vectorcraft_doc::depth::MAX_DEPTH`] deep, and together they replace at
/// most [`crate::SYMBOL_ART_MAX_NODES`] members: past either limit, members stay as they are.
@@ -848,6 +856,13 @@ pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) {
let Some(_frame) = DrawnFrame::enter() else { return };
let clip = usize::from(n.clips());
let Some(ch) = n.children_mut() else { return };
// The clipping path's own geometry effects shape it before the effects around it do.
if clip == 1
&& let Some(first) = ch.first_mut()
&& let Some(applied) = crate::clip::with_clip_effects_applied(first)
{
*first = Arc::new(applied);
}
for c in ch.iter_mut().skip(clip).filter(|c| c.visible) {
if !DrawnFrame::take_member() {
return;
+40 -3
View File
@@ -747,9 +747,9 @@ fn a_baked_group_that_keeps_its_type_applies_its_members_effects_first() {
assert!((w - 250.0).abs() < 1e-6, "{w}");
}
/// A clip group's clipping path keeps its own effects; the clipped members take theirs first.
/// A clip group's clipping path and the clipped members take their own effects first.
#[test]
fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() {
fn a_clipping_path_takes_its_own_effects_before_a_groups_effects() {
let mut clip = with_effect(square_at_100(), offset_10());
clip.id = NodeId(2);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
@@ -762,7 +762,9 @@ fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() {
let clip_group = art.children().unwrap()[0].clone();
let ch = clip_group.children().unwrap();
assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0].kind);
assert_eq!(crate::reshape::geometry_effects(&ch[0]).len(), 1, "the clipping path keeps its Offset Path");
assert!(crate::reshape::geometry_effects(&ch[0]).is_empty(), "the clipping path's Offset Path is applied");
let w = ch[0].geometric_bounds().unwrap().width();
assert!((w - 60.0).abs() < 0.5, "grown by 10 on each side, then scaled to 50 %: {w}");
assert!(matches!(ch[1].kind, NodeKind::Group { .. }) && crate::reshape::geometry_effects(&ch[1]).is_empty(), "the member is drawn as its art");
}
@@ -1757,3 +1759,38 @@ fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked()
assert_eq!(guides, [(NodeId(20), !hidden, locked)], "hidden {hidden}, locked {locked}");
}
}
/// A group's own Fill + Offset Path below its contents follows the outline of a clip group among
/// its members, its clipping path, not the clipped art it hides outside that path (#7289).
#[test]
fn a_groups_own_fill_follows_a_clip_groups_clipping_path_not_its_hidden_art() {
let mut clip = square_at_100();
clip.id = NodeId(2);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
// The clipped art reaches 100 pt past the clipping path on either side.
let hidden = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 100.0, 300.0, 200.0)), black());
let clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(hidden)], clip: true });
let mut g = Node::group(NodeId(10), vec![Arc::new(clipped.clone())]);
let mut fill = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
fill.effects.push(offset_10());
g.appearance.items = vec![AppearanceItem::Fill(fill)];
g.appearance.set_contents_at(1);
let near = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y1 - b.y1).abs() < 0.5;
let expected = Rect::new(90.0, 90.0, 210.0, 210.0);
let art = evaluate_container(None, &g).unwrap();
let ch = art.children().unwrap();
assert_eq!(ch.len(), 2, "the fill's art below the clip group");
assert_eq!(ch[0].id, NodeId(10));
let b = drawn_bounds(&ch[0]).unwrap();
assert!(near(b, expected), "the offset fill hugs the clipping path: {b:?}");
// The clip group itself with that fill: the same outline.
let mut own = clipped;
own.appearance = g.appearance.clone();
assert_eq!(member_shapes(None, &own).len(), 1);
let b = member_shapes(None, &own)[0].0.bounds().unwrap();
assert!(near(b, Rect::new(100.0, 100.0, 200.0, 200.0)), "the clipping path's region: {b:?}");
}
+14 -4
View File
@@ -689,18 +689,28 @@ fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook,
}
}
/// Type `t` in a clip group's clipping path as its outlines ([`outline_text`]) with its
/// visibility, or with geometry effects as a path of the region they give it, which is what
/// [`crate::clip_outline`] clips by.
pub(crate) fn clipping_text_outline(t: &Node) -> Option<Node> {
if let Some(p) = crate::clip::with_clip_effects_applied(t) {
return Some(p);
}
let o = outline_text(t)?;
Some(Node { visible: t.visible, ..o })
}
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes
/// its plain outlines ([`outline_text`]) with its visibility, which is what
/// [`crate::clip_outline`] clips by. The swatches and patterns that live-effect plug-ins painting
/// their pieces name are looked up in `doc`.
/// its outlines with its visibility and effects ([`clipping_text_outline`]). The swatches and
/// patterns that live-effect plug-ins painting their pieces name are looked up in `doc`.
pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) {
let clips = n.clips();
let Some(ch) = n.children_mut() else { return };
for (i, c) in ch.iter_mut().enumerate() {
let clipping = clipping || (clips && i == 0);
let art = match &c.kind {
NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
NodeKind::Text(_) if clipping => clipping_text_outline(c),
NodeKind::Text(_) => as_art(doc, c, None),
_ => None,
};
+55 -24
View File
@@ -370,7 +370,9 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R
Ok(crate::solids::shadow::scoped_art(id, &caster, settings, cast, crate::solids::has_masks(shapes)))
}
/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts
/// One object's surface of revolution about the axis of `b`, and each face it draws ([`Drawn`]),
/// back to front: by depth, and where two faces overlap, by their depths there
/// ([`crate::solids::surface_order`]). `copies` counts
/// the bytes of the paint copies of every surface and graphic of the object. Revolve (Classic) draws every face
/// with its classic surface, as Extrude & Bevel (Classic) does. Revolve's graphics, with the
/// symbols of `doc`, are drawn on the faces of the side ([`crate::solids::graphics::surface_parts`]):
@@ -388,7 +390,7 @@ fn surface(
blend: vectorcraft_doc::color::BlendMode,
expand: bool,
copies: &mut Copies,
) -> Option<(Vec<f64>, Node)> {
) -> Option<(Vec<Drawn>, Node)> {
// A loaded file or a profile edited past the limits keeps drawing its source path instead
// of crashing the frame. Commands validate the same evaluator and report its errors.
if fx.id == CLASSIC_REVOLVE {
@@ -397,9 +399,12 @@ fn surface(
if copies.surfaces > MAX_PAINT_COPIES {
return None;
}
let depths = facets.iter().map(|f| f.depth).collect();
let order = crate::solids::surface_order(facets.iter().map(|f| (f.contours.first().map_or(&[][..], Vec::as_slice), f.plane, f.depth)));
let facets = in_order(facets, &order);
let drawn =
facets.iter().map(|f| Drawn { depth: f.depth, points: f.contours.first().cloned().unwrap_or_default(), plane: f.plane }).collect();
let p = merged_params(&fx.id, &fx.params);
return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p)));
return Some((drawn, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p)));
}
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
@@ -407,6 +412,8 @@ fn surface(
if copies.surfaces > MAX_PAINT_COPIES {
return None;
}
let order = crate::solids::surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth)));
let faces = in_order(faces, &order);
let graphics = crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params));
let shade = |face: &vectorcraft_three_d::Face, c: Color| graphic_paint(face, c, colored);
// Graphics past the limits are left out, and the surface draws without them and their paints'
@@ -437,21 +444,28 @@ fn surface(
&& n.has_default_transparency())
.then(|| vectorcraft_three_d::visible_faces(&faces).ok())
.flatten();
let faces = visible.unwrap_or_else(|| {
faces
.into_iter()
.map(|f| vectorcraft_three_d::VisibleFace {
path: f.path(),
brightness: f.brightness,
depth: f.depth,
gradient: f.gradient,
illumination: f.illumination,
light_gradient: f.light_gradient,
rgb_gradient: f.rgb_gradient,
})
.collect()
});
let depths: Vec<f64> = faces.iter().map(|f: &vectorcraft_three_d::VisibleFace| f.depth).collect();
let (faces, keys): (Vec<vectorcraft_three_d::VisibleFace>, Vec<Drawn>) = match visible {
Some(visible) => {
let keys = visible.iter().map(|f| Drawn { depth: f.depth, points: vec![], plane: None }).collect();
(visible, keys)
}
None => {
let keys = faces.iter().map(|f| Drawn { depth: f.depth, points: f.points.clone(), plane: f.plane }).collect();
let faces = faces
.into_iter()
.map(|f| vectorcraft_three_d::VisibleFace {
path: f.path(),
brightness: f.brightness,
depth: f.depth,
gradient: f.gradient,
illumination: f.illumination,
light_gradient: f.light_gradient,
rgb_gradient: f.rgb_gradient,
})
.collect();
(faces, keys)
}
};
let children = faces
.into_iter()
.map(|face| {
@@ -517,7 +531,21 @@ fn surface(
let mut group = Node::group(n.id, children);
group.opacity = opacity;
group.blend = blend;
Some((depths, group))
Some((keys, group))
}
/// A face [`surface`] draws: its depth and, for [`crate::solids::surface_order`], its polygon on the
/// drawing and its plane (none for a face trimmed to the part of it that shows).
struct Drawn {
depth: f64,
points: Vec<Point>,
plane: Option<vectorcraft_three_d::DepthPlane>,
}
/// `items` in `order`, a permutation of their indices.
fn in_order<T>(items: Vec<T>, order: &[usize]) -> Vec<T> {
let mut slots: Vec<Option<T>> = items.into_iter().map(Some).collect();
order.iter().filter_map(|&k| slots.get_mut(k).and_then(Option::take)).collect()
}
/// A surface of one object about the bounds of `path`, built with options derived from `fx`.
@@ -934,14 +962,17 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
} else {
let count = surfaces.len();
let mut faces = vec![];
for (member, (depths, s, scopes)) in surfaces.into_iter().enumerate() {
for (member, (drawn, s, scopes)) in surfaces.into_iter().enumerate() {
let (opacity, blend) = (s.opacity, s.blend);
if let NodeKind::Group { children, .. } = s.kind {
faces.extend(depths.into_iter().zip(children).map(|(depth, face)| (depth, member, opacity, blend, scopes, face)));
faces.extend(drawn.into_iter().zip(children).map(|(drawn, face)| (drawn, member, opacity, blend, scopes, face)));
}
}
// A stable sort: faces at the same depth keep the members' order.
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
// A stable sort: faces at the same depth keep the members' order. Overlapping faces, of one
// member or of two, then go in the order of their depths where they overlap.
faces.sort_by(|a, b| a.0.depth.total_cmp(&b.0.depth));
let order = crate::solids::surface_order(faces.iter().map(|(d, ..)| (d.points.as_slice(), d.plane, d.depth)));
let faces = in_order(faces, &order);
if opaque {
vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(.., face)| face).collect()))]
} else {
+58 -3
View File
@@ -223,6 +223,10 @@ pub(crate) fn solid_paint_sources(n: &Node) -> (Vec<Option<&AppearanceItem>>, Ve
}
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
#[cfg(test)]
if !is_plane(id) {
SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1));
}
let view = view_options(p);
match id {
EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view),
@@ -292,7 +296,7 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at:
let mut shapes = members(n, label)?;
reshape_members(n, before, &mut shapes);
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))?;
check_profiles(id, p, &shapes, b)?;
check_solids(id, p, &shapes)?;
return cast_shadow(id, p, &shapes).map(|_| ());
}
@@ -309,12 +313,24 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at:
None => (geometry(&path, before, &ctx), solid_paints(n)),
};
let b = path.bounds().ok_or_else(anchors)?;
facets(id, &path, rule, b, p)?;
let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }];
check_profiles(id, p, &shapes, b)?;
check_solids(id, p, &shapes)?;
cast_shadow(id, p, &shapes).map(|_| ())
}
/// Do the solids 3D effect `id` with parameters `p` builds from the profiles of `shapes` about `b`
/// build? Only the profiles no solid of [`check_solids`] is built from are built here: every
/// shape's for Rotate, and for Extrude & Bevel and Inflate the profile of a shape with no fill,
/// whose solids are its strokes' outlines or none. A filled shape's profile is its fill's solid,
/// which [`check_solids`] builds once, about the bounds the effect draws it about.
fn check_profiles(id: &str, p: &Value, shapes: &[Profile], b: Rect) -> Result<(), String> {
shapes
.iter()
.filter(|s| is_plane(id) || !s.items.iter().any(|i| matches!(i, AppearanceItem::Fill(_))))
.try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))
}
/// Do the solids 3D effect `id` with parameters `p` builds from the fills and strokes of `shapes`
/// fit, each on its own and all of them together ([`built_solids`])? Rotate's planes are not
/// counted.
@@ -658,6 +674,9 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<Part<'_>>, Rect) {
thread_local! {
/// How many solids [`built_solids`] and [`meshes_for`] have built on this thread, for the tests.
static SOLIDS_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// How many Extrude & Bevel (Classic included) and Inflate solids [`facets`] and
/// [`built_solids`] have built on this thread, for the tests.
static SOLID_GEOMETRY_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The solids Extrude & Bevel (Classic included) or Inflate `id` with parameters `p` builds from
@@ -687,7 +706,11 @@ fn built_solids<'a>(
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + 1));
let built = match inflated.as_mut() {
Some(each) => each.next().unwrap_or_else(|| Ok(vec![])),
Some(each) => {
#[cfg(test)]
SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1));
each.next().unwrap_or_else(|| Ok(vec![]))
}
None => facets(id, area, *rule, bounds, p),
};
let result = match built {
@@ -1261,6 +1284,35 @@ fn draw_order(faces: &[Placed], sealed: bool) -> Vec<usize> {
local_order(faces, order)
}
/// The order, back to front, of the faces of one or more Revolve surfaces `faces`, given back to
/// front by depth: each face's polygon on the drawing, its plane and its depth. When two
/// overlapping faces are drawn the wrong way round for their depths where they overlap, every pair
/// of overlapping faces with a convex polygon and a plane goes in the order of its depths there,
/// the face nearer the viewer after the other ([`local_order`]), and each step draws the face that
/// comes first in the given order of those whose faces before are drawn, so a face without a plane
/// or a convex polygon can also move. Otherwise the order given.
pub(crate) fn surface_order<'a>(faces: impl IntoIterator<Item = (&'a [Point], Option<DepthPlane>, f64)>) -> Vec<usize> {
let placed: Vec<Placed> = faces
.into_iter()
.map(|(points, plane, depth)| {
let local = plane.is_some() && convex(points).is_some();
// Every face counts as one of a stroke's on one shape, so each overlapping pair is compared.
Placed {
depth,
side: true,
away: false,
bounds: polygon_bounds(points),
member: 0,
paints: Paints::Other,
outline: if local { points.to_vec() } else { vec![] },
plane: plane.filter(|_| local),
}
})
.collect();
let order = (0..placed.len()).collect();
local_order(&placed, order)
}
/// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before`
/// its 3D effect, about the bounds of all of them.
pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
@@ -2178,3 +2230,6 @@ mod tests_sources;
#[cfg(test)]
mod tests_shadow;
#[cfg(test)]
mod tests_revolve_order;
+42
View File
@@ -953,6 +953,48 @@ fn an_objects_solids_count_their_faces_together() {
}
}
/// How many Extrude & Bevel and Inflate solids `check` builds, and its result.
fn geometry_builds(check: impl FnOnce() -> Result<(), String>) -> (usize, Result<(), String>) {
SOLID_GEOMETRY_BUILT.with(|b| b.set(0));
let result = check();
(SOLID_GEOMETRY_BUILT.with(std::cell::Cell::get), result)
}
/// The check builds each solid the effect draws once, about the bounds it draws it about: one for
/// a fill and one for each stroke, on an object, on a group's members and on an appearance item,
/// with the shadow on or off.
#[test]
fn the_check_builds_each_drawn_solid_once() {
for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] {
let n = square_node();
assert_eq!(geometry_builds(|| validate_solid(&n, id, &json!({}))), (2, Ok(())), "{id}: a fill and a stroke");
let mut other = square_node();
other.id = NodeId(2);
let g = Node::group(NodeId(9999), vec![Arc::new(square_node()), Arc::new(other)]);
assert_eq!(geometry_builds(|| validate_solid(&g, id, &json!({}))), (4, Ok(())), "{id}: two members' fills and strokes");
}
for id in [EXTRUDE, INFLATE] {
let n = square_node();
assert_eq!(geometry_builds(|| validate_three_d_at(&n, id, &json!({}), (Some(0), 0))), (1, Ok(())), "{id}: on the fill");
let shadow = json!({"shadow": true});
assert_eq!(geometry_builds(|| validate_solid(&n, id, &shadow)), (2, Ok(())), "{id}: with the shadow on");
}
}
/// A shape with no fill draws no solid of its profile, so the check builds the profile on its own
/// as well as the strokes' solids, and an unpainted shape's profile alone.
#[test]
fn the_check_builds_the_profile_of_a_shape_with_no_fill() {
for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] {
let mut stroked = square_node();
stroked.appearance.items.retain(|i| matches!(i, AppearanceItem::Stroke(_)));
assert_eq!(geometry_builds(|| validate_solid(&stroked, id, &json!({}))), (2, Ok(())), "{id}: a stroke alone");
let mut bare = square_node();
bare.appearance.items.clear();
assert_eq!(geometry_builds(|| validate_solid(&bare, id, &json!({}))), (1, Ok(())), "{id}: no paint");
}
}
/// A convex polygon of `corners` corners about (20,000, 20,000), filled red, with no stroke.
fn polygon_node(id: u64, corners: usize) -> Node {
let points: Vec<Point> = (0..corners)
@@ -0,0 +1,183 @@
use serde_json::json;
use super::*;
use crate::revolve::{CLASSIC_REVOLVE, REVOLVE, revolve_art, revolve_options};
/// The first pair of faces `(polygon, plane)`, in the order given, drawn the wrong way round where
/// their polygons overlap: the earlier one nearer the viewer at the middle of the overlap.
fn misordered(faces: &[(Vec<Point>, Option<DepthPlane>)]) -> Option<(usize, usize)> {
for (i, (a, pa)) in faces.iter().enumerate() {
let Some(pa) = pa.filter(|_| convex(a).is_some()) else { continue };
for (j, (b, pb)) in faces.iter().enumerate().skip(i + 1) {
let Some(pb) = pb else { continue };
let Some(turn) = convex(b) else { continue };
if !rects_overlap(polygon_bounds(a), polygon_bounds(b)) {
continue;
}
let Some(mid) = centroid(&clip_convex(a, b, turn)) else { continue };
let (Some(da), Some(db)) = (pa.depth_at(mid), pb.depth_at(mid)) else { continue };
if da - db > 1e-6 * (1.0 + da.abs().max(db.abs())) {
return Some((i, j));
}
}
}
None
}
/// An open, vase-like profile of straight edges.
fn vase() -> PathData {
let mut b = BezPath::new();
b.move_to((30.0, 20.0));
for p in [(90.0, 20.0), (55.0, 65.0), (95.0, 110.0), (30.0, 145.0)] {
b.line_to(p);
}
PathData::from_bezpath(&b)
}
/// The `k`th of five closed 14 × 20 pt rectangles stepping down and away from the axis, as the
/// dashes of a dashed line's outline do.
fn dash(k: usize) -> PathData {
let (x, y) = (30.0 + 12.0 * k as f64, 20.0 + 26.0 * k as f64);
let mut b = BezPath::new();
b.move_to((x, y));
b.line_to((x + 14.0, y));
b.line_to((x + 14.0, y + 20.0));
b.line_to((x, y + 20.0));
b.close_path();
PathData::from_bezpath(&b)
}
/// The five [`dash`]es as one compound path.
fn dashes() -> PathData {
PathData::new((0..5).flat_map(|k| dash(k).subpaths).collect())
}
/// A tilted three-quarter turn of [`vase`], whose faces sorted by their middles' depths draw a
/// nearer face under a farther one where the two overlap.
fn params() -> Value {
json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 24, "cap": true})
}
/// A three-quarter turn of [`dashes`], steeply tilted and in wide perspective, whose classic facets
/// sorted by their middles' depths draw a nearer facet under a farther one where the two overlap.
fn classic_params() -> Value {
json!({"angle": 270, "offset": 12, "rotationX": -60, "rotationY": 0, "perspectiveAngle": 80, "cap": true})
}
fn node(path: PathData) -> Node {
Node::path(NodeId(1), path, Appearance::basic(Paint::solid(Color::rgb(0.1, 0.7, 0.8)), Paint::None, 0.0))
}
/// The faces of `faces` (polygon, plane and outline) in the order `art` draws their outlines.
fn drawn_order(art: &Node, faces: &[(Vec<Point>, Option<DepthPlane>, PathData)]) -> Vec<(Vec<Point>, Option<DepthPlane>)> {
let mut out = vec![];
art.walk(&mut |n| {
if let Some(path) = n.path_data()
&& let Some((points, plane, _)) = faces.iter().find(|(.., outline)| outline == path)
{
out.push((points.clone(), *plane));
}
});
out
}
/// Of a tilted, revolved vase, a face sorted by the depth of its middle goes under a farther face
/// that it is in front of where the two overlap. The surface draws the nearer face last there, on
/// its own and as a member of a revolved group.
#[test]
fn a_revolved_surface_draws_the_nearer_of_two_overlapping_faces_last() {
let path = vase();
let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(&params())).unwrap();
let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect();
assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere");
let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect();
let mut n = node(path.clone());
n.appearance.effects.push(crate::new_effect(REVOLVE, &params()).unwrap());
let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed);
assert_eq!(drawn.len(), faces.len());
assert_eq!(misordered(&drawn), None, "an object");
let mut g = Node::group(NodeId(9), vec![Arc::new(node(path))]);
g.appearance.effects.push(crate::new_effect(REVOLVE, &params()).unwrap());
let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed);
assert_eq!(drawn.len(), faces.len());
assert_eq!(misordered(&drawn), None, "a group's member");
}
/// The faces of a group's members, sorted together by the depths of their middles, go in the order
/// of their depths where they overlap, those of two members as well as those of one.
#[test]
fn a_revolved_groups_members_draw_the_nearer_of_two_overlapping_faces_last() {
let p = json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 16, "cap": true});
let members: Vec<PathData> = (0..5).map(dash).collect();
let b = dashes().bounds().unwrap();
let mut faces: Vec<_> = members.iter().flat_map(|m| vectorcraft_three_d::revolve(m, b, revolve_options(&p)).unwrap()).collect();
faces.sort_by(|a, b| a.depth.total_cmp(&b.depth));
let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect();
assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere");
let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect();
let mut g = Node::group(NodeId(9), members.into_iter().map(|m| Arc::new(node(m))).collect());
g.appearance.effects.push(crate::new_effect(REVOLVE, &p).unwrap());
let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed);
assert_eq!(drawn.len(), faces.len());
assert_eq!(misordered(&drawn), None);
}
/// Revolve (Classic) draws the nearer of two overlapping facets last where they overlap, too.
#[test]
fn a_classic_revolved_surface_draws_the_nearer_of_two_overlapping_facets_last() {
let path = dashes();
// Revolve (Classic) sweeps its surface unshaded, with one facet for each cell.
let classic = vectorcraft_three_d::Revolve {
shade: false,
smooth_shading: false,
lights: None,
light_color: [1.0; 3],
..revolve_options(&crate::merged_params(CLASSIC_REVOLVE, &classic_params()))
};
let facets = vectorcraft_three_d::revolve_facets(&path, path.bounds().unwrap(), classic).unwrap();
let by_depth: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane)).collect();
assert!(misordered(&by_depth).is_some(), "the facets by depth are drawn the wrong way round somewhere");
let keyed: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane, f.path())).collect();
let mut n = node(path);
n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &classic_params()).unwrap());
let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed);
assert_eq!(drawn.len(), facets.len());
assert_eq!(misordered(&drawn), None);
}
/// A surface whose faces by depth are already drawn the right way round wherever they overlap keeps
/// that order.
#[test]
fn a_surface_already_in_order_keeps_it() {
let path = vase();
let p = json!({"angle": 360, "offset": 12, "rotationX": 0, "rotationY": 0, "segments": 24, "cap": true});
let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(&p)).unwrap();
let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect();
assert_eq!(misordered(&by_depth), None);
let order = surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth)));
assert_eq!(order, (0..faces.len()).collect::<Vec<_>>());
}
/// Of two overlapping faces, the one nearer the viewer where they overlap goes after the other,
/// whatever their depths; a face without a plane, and one that overlaps neither, keep their places.
/// Two faces at the same depth there keep their order.
#[test]
fn surface_order_puts_the_nearer_face_where_they_overlap_last() {
let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)];
// z = 10 - x: at the middle of its overlap with the next square (x 7.5) it is at z 2.5.
let r = std::f64::consts::FRAC_1_SQRT_2;
let sloped = Some(DepthPlane { normal: [r, 0.0, r], offset: 10.0 * r, center: Point::ZERO, eye: None });
let flat = |z: f64| Some(DepthPlane { normal: [0.0, 0.0, 1.0], offset: z, center: Point::ZERO, eye: None });
let (a, b, apart) = (square(0.0), square(5.0), square(40.0));
let faces = [(&a[..], sloped, 5.0), (&apart[..], flat(0.0), 1.0), (&b[..], flat(3.0), 3.0)];
assert_eq!(surface_order(faces), vec![0, 1, 2], "the flat face, nearer at the overlap, is already last");
let faces = [(&b[..], flat(2.0), 2.0), (&apart[..], flat(0.0), 3.0), (&a[..], sloped, 5.0)];
assert_eq!(surface_order(faces), vec![0, 1, 2], "the sloped face, nearer at the overlap, is already last");
// The sloped face's middle (x 5) is at z 5, but the flat face at z 3 is nearer at the overlap.
let faces = [(&b[..], flat(3.0), 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)];
assert_eq!(surface_order(faces), vec![1, 2, 0], "the flat face is nearer at the overlap");
let faces = [(&b[..], None, 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)];
assert_eq!(surface_order(faces), vec![0, 1, 2], "no plane");
let faces = [(&b[..], flat(2.5), 3.0), (&a[..], sloped, 5.0)];
assert_eq!(surface_order(faces), vec![0, 1], "the same depth there");
}
+1
View File
@@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
use super::*;
mod clipfx;
mod imageoutline;
mod painted;
mod rasterfollow;
+401
View File
@@ -0,0 +1,401 @@
//! Live effects on the clipping path of a clip group: the region it clips to takes the shape its
//! geometry effects give it, on the canvas as in an export.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_geom::{Rect, Shape, shapes};
use crate::{bake_document, clip_outline};
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// A 100 pt square clipping path at (50, 50) with `effects`.
fn clipping_path(id: u64, effects: Vec<Effect>) -> Node {
let mut p = Node::path(NodeId(id), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut p.kind {
*clipping = true;
}
p.appearance.effects = effects;
p
}
/// A clip group: `clip` clipping a red 200 pt square at the origin.
fn clip_group(id: u64, clip: Node) -> Node {
let art = Node::path(
NodeId(id + 100),
shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)),
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
);
Node::new(NodeId(id), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true })
}
fn bounds(n: &Node) -> Rect {
clip_outline(n).map(|(bp, _)| bp.bounding_box()).unwrap()
}
/// The clipping path of the first clip group in `doc` after an export bakes it.
fn baked_clip(doc: &Document) -> Arc<Node> {
fn first(n: &Arc<Node>) -> Option<Arc<Node>> {
match &n.kind {
NodeKind::Group { children, clip: true } => children.first().cloned(),
_ => n.children()?.iter().find_map(first),
}
}
let baked = bake_document(doc).unwrap();
baked.layers.iter().find_map(first).unwrap()
}
#[test]
fn transform_moves_the_clip() {
let clip = clipping_path(1, vec![fx("distort.transform", json!({"moveH": 60}))]);
let b = bounds(&clip);
assert!((b.x0 - 110.0).abs() < 1e-6 && (b.x1 - 210.0).abs() < 1e-6, "the clip moves with the clipping path: {b:?}");
assert!((b.y0 - 50.0).abs() < 1e-6 && (b.y1 - 150.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn roughen_clips_as_an_export_does() {
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
let group = clip_group(1, clipping_path(2, vec![fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false}))]));
d.insert(Some(layer), 0, group.clone()).unwrap();
let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") };
let live = clip_outline(&children[0]).unwrap();
let exported = clip_outline(&baked_clip(&d)).unwrap();
assert_eq!(live.1, exported.1);
assert_eq!(live.0, exported.0, "the canvas clips by the outline the export writes");
assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0), "the roughened outline, not the plain square");
}
#[test]
fn nested_members_keep_their_effects() {
// A group as the clipping path: its member's Transform effect moves its part of the region.
let moved = clipping_path(2, vec![fx("distort.transform", json!({"moveV": 60}))]);
let group = Node::group(NodeId(1), vec![Arc::new(moved)]);
let b = bounds(&group);
assert!((b.y0 - 110.0).abs() < 1e-6 && (b.y1 - 210.0).abs() < 1e-6, "{b:?}");
// A clip group as the clipping path clips by its own clipping path, effects and all.
let nested = clip_group(3, clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))]));
let b = bounds(&nested);
assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}");
// So does the nested clip group's own effect.
let mut nested = clip_group(7, clipping_path(8, vec![]));
nested.appearance.effects.push(fx("distort.transform", json!({"moveH": 30})));
let b = bounds(&nested);
assert!((b.x0 - 80.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6, "{b:?}");
// The group's own Transform effect applies on top of its members'.
let mut group = Node::group(NodeId(5), vec![Arc::new(clipping_path(6, vec![fx("distort.transform", json!({"moveV": 60}))]))]);
group.appearance.effects.push(fx("distort.transform", json!({"moveH": 10})));
let b = bounds(&group);
assert!((b.x0 - 60.0).abs() < 1e-6 && (b.y0 - 110.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn a_roughened_group_clips_as_an_export_does() {
// Two members side by side, Roughen on the group: each member is roughened, as in the export.
let member = |id: u64, x: f64| {
Arc::new(Node::path(
NodeId(id),
shapes::rectangle(Rect::new(x, 50.0, x + 40.0, 150.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
))
};
let mut clip = Node::group(NodeId(2), vec![member(3, 50.0), member(4, 110.0)]);
clip.appearance.effects.push(fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false})));
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, clip_group(1, clip.clone())).unwrap();
let live = clip_outline(&clip).unwrap();
assert_eq!(live.0, clip_outline(&baked_clip(&d)).unwrap().0, "the canvas clips by the outline the export writes");
assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0));
}
#[test]
fn a_compound_paths_member_keeps_its_effect() {
// A ring whose hole a Transform effect moves 20 pt right, as the export bakes it.
let ring = |effects: Vec<Effect>| {
let outer = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default());
let mut hole = Node::path(NodeId(4), shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0)), Appearance::default());
hole.appearance.effects = effects;
Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(hole)], rule: vectorcraft_geom::FillRule::EvenOdd })
};
let moved = ring(vec![fx("distort.transform", json!({"moveH": 20}))]);
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, clip_group(1, moved.clone())).unwrap();
let live = clip_outline(&moved).unwrap();
assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes");
assert_ne!(live, clip_outline(&ring(vec![])).unwrap(), "the hole moves");
// With a Transform of the compound path's own moving the whole ring 10 pt down after it.
let mut both = moved.clone();
both.appearance.effects.push(fx("distort.transform", json!({"moveV": 10})));
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, clip_group(1, both.clone())).unwrap();
let live = clip_outline(&both).unwrap();
assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes");
assert_ne!(live.0.bounding_box(), clip_outline(&moved).unwrap().0.bounding_box(), "the ring moves down");
}
#[test]
fn the_clipping_paths_effects_apply_before_the_clip_groups() {
// Scale 50 % on the clipping path, then Offset Path 10 on its clip group: the region is the
// half-size square grown by 10, as if the clipping path had been drawn at half size.
let group = |clip: Node| {
let mut g = clip_group(1, clip);
g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10})));
g
};
let scaled = group(clipping_path(2, vec![fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))]));
let mut half = clipping_path(2, vec![]);
if let NodeKind::Path { path, .. } = &mut half.kind {
*path = shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0));
}
let want = bounds(&group(half));
let got = bounds(&scaled);
assert!((got.x0 - want.x0).abs() < 0.5 && (got.x1 - want.x1).abs() < 0.5, "{got:?} against {want:?}");
assert!((want.x0 - 65.0).abs() < 0.5, "{want:?}");
// The clip group as the canvas evaluates it for its effect, and as an export bakes it.
let evaluated = crate::evaluate_container(None, &scaled).unwrap();
let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") };
let b = clip_outline(&children[0]).unwrap().0.bounding_box();
assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "evaluated: {b:?} against {want:?}");
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, scaled).unwrap();
let b = clip_outline(&baked_clip(&d)).unwrap().0.bounding_box();
assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "exported: {b:?} against {want:?}");
}
#[test]
fn nested_effects_apply_before_the_clip_groups() {
// The clip group grows its clipping path by 10 (Offset Path). Inside the clipping path a
// Transform moves a member 60 pt right: the region grows from the moved member.
let evaluated = |clip: Node| {
let mut g = clip_group(1, clip);
g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10})));
let e = crate::evaluate_container(None, &g).unwrap();
let NodeKind::Group { children, clip: true } = &e.kind else { panic!("not a clip group") };
clip_outline(&children[0]).unwrap().0.bounding_box()
};
let moved = || clipping_path(3, vec![fx("distort.transform", json!({"moveH": 60}))]);
let close = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5;
let want = Rect::new(100.0, 40.0, 220.0, 160.0);
// A group holding the member.
let b = evaluated(Node::group(NodeId(2), vec![Arc::new(moved())]));
assert!(close(b, want), "group: {b:?}");
// A compound path of the member, with a Transform of its own moving it 10 pt down.
let mut compound = Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(moved())], rule: vectorcraft_geom::FillRule::NonZero });
compound.appearance.effects.push(fx("distort.transform", json!({"moveV": 10})));
let b = evaluated(compound);
assert!(close(b, Rect::new(100.0, 50.0, 220.0, 170.0)), "compound: {b:?}");
}
#[test]
fn clipping_type_keeps_its_effect_where_other_type_stays_type() {
// Type moved 30 pt right by its Transform clips a red square, beside other type, in a group
// that a Transform moves 10 pt down: the export keeps the other type as type and outlines the
// clipping type, which still clips where the canvas clips.
use vectorcraft_doc::text::{CharStyle, TextObject};
let text = |id: u64, effects: Vec<Effect>| {
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut t = Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style))));
t.appearance.effects = effects;
t
};
let clipped = clip_group(2, text(3, vec![fx("distort.transform", json!({"moveH": 30}))]));
let mut outer = Node::group(NodeId(1), vec![Arc::new(clipped), Arc::new(text(5, vec![]))]);
outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 10})));
fn clip_of(n: &Node) -> Option<Rect> {
match &n.kind {
NodeKind::Group { children, clip: true } => Some(clip_outline(children.first()?)?.0.bounding_box()),
_ => n.children()?.iter().find_map(|c| clip_of(c)),
}
}
if vectorcraft_text::CRAFT_FONTS.is_empty() {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
return;
}
let plain = clip_outline(&text(3, vec![])).unwrap().0.bounding_box();
let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap()).unwrap();
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, outer).unwrap();
let baked = bake_document(&d).unwrap();
let mut exported = None;
for l in &baked.layers {
exported = exported.or_else(|| clip_of(l));
}
let exported = exported.unwrap();
assert!((canvas.x0 - plain.x0 - 30.0).abs() < 0.5 && (canvas.y0 - plain.y0 - 10.0).abs() < 0.5, "{canvas:?} from {plain:?}");
assert!((exported.x0 - canvas.x0).abs() < 0.5 && (exported.y0 - canvas.y0).abs() < 0.5, "exported {exported:?}, canvas {canvas:?}");
// The outlined clipping type keeps its black fill, which paints behind the clipped art.
let outlined = crate::reshape::clipping_text_outline(&text(3, vec![fx("distort.transform", json!({"moveH": 30}))])).unwrap();
fn fills(n: &Node) -> usize {
n.appearance.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Fill(f) if f.paint != Paint::None)).count()
+ n.children().into_iter().flatten().map(|c| fills(c)).sum::<usize>()
}
assert!(fills(&outlined) > 0, "{outlined:?}");
}
#[test]
fn a_painted_clipping_paths_gradient_follows_its_effect() {
// A clipping path filled with a gradient placed across it, moved 60 pt right by its Transform
// effect, in a clip group with an effect of its own: the gradient moves with the path.
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)];
let geom = GradientGeom { start: (50.0, 100.0).into(), end: (150.0, 100.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
let mut clip = clipping_path(2, vec![fx("distort.transform", json!({"moveH": 60}))]);
clip.appearance = Appearance { effects: clip.appearance.effects.clone(), ..Appearance::basic(placed, Paint::None, 0.0) }.into();
let mut group = clip_group(1, clip);
group.appearance.effects.push(fx("path.offsetPath", json!({"offset": 0})));
let evaluated = crate::evaluate_container(None, &group).unwrap();
let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") };
let Paint::Gradient(g) = children[0].appearance.fill_paint() else { panic!("no gradient") };
let start = g.geom.unwrap().start;
assert!((start.x - 110.0).abs() < 1e-6, "the gradient starts at the moved path's left edge: {start:?}");
}
#[test]
fn a_nested_clip_groups_hidden_clipping_path_keeps_its_effect() {
// A clip group used as a clipping path clips by its clipping path whether it is shown or not.
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))]);
hidden.visible = false;
let outer = clip_group(1, clip_group(3, hidden));
d.insert(Some(layer), 0, outer.clone()).unwrap();
let NodeKind::Group { children, .. } = &outer.kind else { panic!("not a group") };
let b = bounds(&children[0]);
assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}");
assert_eq!(clip_outline(&children[0]), clip_outline(&baked_clip(&d)), "the canvas clips by the outline the export writes");
}
#[test]
fn a_fills_geometry_effect_shapes_the_clip_as_an_export_does() {
// The clipping path's fill has a Transform effect moving it 60 pt right; the path has none.
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
let mut clip = clipping_path(2, vec![]);
clip.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0).into();
for item in &mut clip.appearance.items {
if let vectorcraft_doc::AppearanceItem::Fill(f) = item {
f.effects.push(fx("distort.transform", json!({"moveH": 60})));
}
}
let group = clip_group(1, clip);
d.insert(Some(layer), 0, group.clone()).unwrap();
let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") };
let live = clip_outline(&children[0]).unwrap();
let exported = clip_outline(&baked_clip(&d)).unwrap();
assert_eq!(live.0.bounding_box(), exported.0.bounding_box(), "the canvas clips by the outline the export writes");
assert!((live.0.bounding_box().x1 - 210.0).abs() < 1e-6, "the moved fill reaches 210: {:?}", live.0.bounding_box());
// A path of a compound clipping path with a fill effect of its own stays part of the region.
let mut member = clipping_path(4, vec![]);
member.appearance = children[0].appearance.clone();
let other = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default());
let compound =
Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(member), Arc::new(other)], rule: vectorcraft_geom::FillRule::NonZero });
assert_eq!(bounds(&compound), Rect::new(0.0, 0.0, 150.0, 150.0));
}
#[test]
fn a_fills_geometry_effect_shapes_the_clip_before_the_effects_around_it() {
// Offset Path 10 on the fill of an unpainted clipping path, in a group that its Transform
// scales to 50 %: the region is the grown square at half size, 60 pt across, as it is when the
// clipping path is the member of a group that clips by its members.
let clip_with = |fill: Paint| {
let mut clip = clipping_path(3, vec![]);
clip.appearance = Appearance::basic(fill, Paint::None, 0.0).into();
for item in &mut clip.appearance.items {
if let vectorcraft_doc::AppearanceItem::Fill(f) = item {
f.effects.push(fx("path.offsetPath", json!({"offset": 10})));
}
}
clip
};
let scaled = |clip: Node| {
let mut outer = Node::group(NodeId(1), vec![Arc::new(clip_group(2, clip))]);
outer.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50})));
outer
};
fn first_clip(n: &Node) -> Option<Arc<Node>> {
match &n.kind {
NodeKind::Group { children, clip: true } => children.first().cloned(),
_ => n.children()?.iter().find_map(|c| first_clip(c)),
}
}
let clip_of = |n: &Node| clip_outline(&first_clip(n).unwrap()).unwrap().0.bounding_box();
let close = |r: Rect| (r.width() - 60.0).abs() < 0.5 && (r.height() - 60.0).abs() < 0.5;
let nested = clip_of(&crate::evaluate_container(None, &scaled(Node::group(NodeId(4), vec![Arc::new(clip_with(Paint::None))]))).unwrap());
assert!(close(nested), "nested: {nested:?}");
let outer = scaled(clip_with(Paint::None));
let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap());
assert!(close(canvas), "canvas: {canvas:?}");
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, outer).unwrap();
let baked = bake_document(&d).unwrap();
let exported = baked.layers.iter().find_map(|l| first_clip(l)).map(|c| clip_outline(&c).unwrap().0.bounding_box()).unwrap();
assert!(close(exported), "exported: {exported:?}");
// A clipping path that paints its fill keeps it, a path with the fill's effect live.
let painted = first_clip(&crate::evaluate_container(None, &scaled(clip_with(Paint::solid(Color::BLACK)))).unwrap()).unwrap();
assert!(painted.clip_paint().fill.is_some(), "{painted:?}");
}
#[test]
fn a_baked_clipping_path_gives_its_pieces_ids_of_their_own() {
// An unpainted clipping path whose fill and stroke have Transform effects, in a clip group
// without effects: the export bakes it into a piece for each, every object with its own id.
let mut d = Document::new(400.0, 400.0);
let (group, path) = (d.alloc_id(), d.alloc_id());
let mut clip = clipping_path(path.0, vec![]);
clip.appearance = Appearance::basic(Paint::None, Paint::None, 1.0).into();
for item in &mut clip.appearance.items {
match item {
vectorcraft_doc::AppearanceItem::Fill(f) => f.effects.push(fx("distort.transform", json!({"moveH": 60}))),
vectorcraft_doc::AppearanceItem::Stroke(s) => s.effects.push(fx("distort.transform", json!({"moveV": 60}))),
}
}
let layer = d.layers[0].id;
d.insert(Some(layer), 0, clip_group(group.0, clip)).unwrap();
let baked = bake_document(&d).unwrap();
let clip = baked_clip(&d);
assert_eq!(clip.children().map(|c| c.len()), Some(2), "{clip:?}");
fn ids(n: &Node, out: &mut Vec<NodeId>) {
out.push(n.id);
for c in n.children().into_iter().flatten() {
ids(c, out);
}
}
let mut all = vec![];
for l in &baked.layers {
ids(l, &mut all);
}
let unique: std::collections::HashSet<_> = all.iter().collect();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
#[test]
fn a_hidden_member_with_an_effect_adds_nothing() {
let shown = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), Appearance::default());
let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": 200}))]);
hidden.visible = false;
let group = Node::group(NodeId(2), vec![Arc::new(shown), Arc::new(hidden)]);
assert_eq!(bounds(&group), Rect::new(50.0, 50.0, 100.0, 100.0));
}
#[test]
fn raster_and_hidden_effects_leave_the_clip_as_it_is() {
let mut hidden = fx("distort.transform", json!({"moveH": 60}));
hidden.visible = false;
for effects in [vec![], vec![fx("blur.gaussian", json!({"radius": 10}))], vec![hidden]] {
assert_eq!(bounds(&clipping_path(1, effects)), Rect::new(50.0, 50.0, 150.0, 150.0));
}
}
+3 -3
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply as Filter",
["Object", "Plug-ins", "Color Effects"],
None,
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step (none when nothing changes); their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}",
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step (none when nothing changes); their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed}",
has_selection,
filter
),
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Ink Fixes",
["Object", "Plug-ins", "Color Effects"],
None,
"{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}",
"{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}",
has_doc,
print_fixes::run
),
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Nearest Swatch",
["Object", "Plug-ins", "Color Effects"],
None,
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step (none when nothing changes): a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step (none when nothing changes): a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
has_selection,
nearest::apply
),
+11 -4
View File
@@ -531,9 +531,11 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
/// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke
/// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters).
/// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on
/// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and
/// when `f` leaves every target as it was, the document and its undo history stay as they are.
/// What the edit changes on the topmost visible fill and stroke of graph series marks, and on the
/// rows and characters of graph labels, is stored on their graphs in the same step
/// ([`capture_series_changes`], [`super::graph::capture_label_changes`]). Nothing targeted is not
/// an edit, and when `f` leaves every target as it was, the document and its undo history stay as
/// they are.
pub(crate) fn edit_items(
s: &mut Session,
ids: &[NodeId],
@@ -543,7 +545,12 @@ pub(crate) fn edit_items(
fill: bool,
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
) -> Result<()> {
edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited)))
edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| {
let series = capture_series_changes(before, d, edited);
// The rows `f` edited are kept by `edit_items_then`.
let labels = super::graph::capture_label_changes(before, d, edited, false);
Ok(series || labels)
})
}
/// [`edit_items`], then `after` on the document as it was before the edit, the edited document and
+1 -1
View File
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand",
["Object", "Live Paint"],
None,
"{id?, ids?: [id…] (instead of id)} turn Live Paint groups into plain groups of their painted faces and edges → {ids}",
"{id?, ids?: [id…] (instead of id)} turn Live Paint groups into plain groups of the faces and edges that draw, with all their fill and stroke rows: a shown face or edge with an opacity above 0 and a shown fill or stroke row that has paint and an opacity above 0 (a stroke also a weight above 0), whichever row is on top → {ids}",
has_selection_or_ids,
lp_expand
),
+50 -17
View File
@@ -1,7 +1,7 @@
use super::{
Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point,
Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint,
json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, PathData, Point, Rect,
Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, json,
number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
};
/// `options` as stored on a Live Paint group: none for the defaults.
@@ -84,6 +84,35 @@ pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
}
/// What Merge carries from a face or edge to the part it makes again in its place, another path:
/// the appearance, visibility, opacity and blending mode. Gradients fitted to the old outline keep
/// that fit, and the strokes' pen pressure, tilt, bearing and rotation stay behind.
struct Carried {
appearance: Appearance,
visible: bool,
opacity: f32,
blend: vectorcraft_color::BlendMode,
}
impl Carried {
fn of(n: &Node) -> Self {
let mut appearance = (*n.appearance).clone().without_pressure();
if appearance.has_unplaced_gradient()
&& let Some(bounds) = n.geometric_bounds()
{
appearance.pin_gradients(bounds);
}
Self { appearance, visible: n.visible, opacity: n.opacity, blend: n.blend }
}
fn onto(self, n: &mut Node) {
n.appearance = self.appearance.into();
n.visible = self.visible;
n.opacity = self.opacity;
n.blend = self.blend;
}
}
/// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its
/// id, the paint of its faces and edges, and its own properties apart from the Gap Options, which
/// are merged; returns (group, faces, edges).
@@ -95,18 +124,16 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
check_live_paint_takes(d, roots)?;
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
// Paint to carry over: (point inside face, fill) and (point on edge, stroke).
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
.iter()
.filter(|f| !f.appearance.fill_paint().is_none())
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.collect();
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
// Paint to carry over: (point inside face, paint) for faces that draw, and (point on edge,
// paint) for every edge.
let keep_faces: Vec<(Point, Carried)> =
b::faces(&old).iter().filter(|f| draws(f)).filter_map(|f| Some((po::interior_point(f.path_data()?)?, Carried::of(f)))).collect();
let keep_edges: Vec<(Point, Carried)> = b::edges(&old)
.iter()
.filter_map(|e| {
let pd = e.path_data()?;
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone()))
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), Carried::of(e)))
})
.collect();
let mut sources = source_nodes(&old);
@@ -126,20 +153,20 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
*ch = children;
}
g.extras_mut().live_paint_gaps = stored_gap_options(options);
for (pt, paint) in keep_faces {
for (pt, carried) in keep_faces {
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
&& let Some(ch) = g.children_mut()
&& let Some(f) = ch.iter_mut().find(|c| c.id == fid)
{
Arc::make_mut(f).appearance.set_fill(paint);
carried.onto(Arc::make_mut(f));
}
}
for (pt, app) in keep_edges {
for (pt, carried) in keep_edges {
if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id)
&& let Some(ch) = g.children_mut()
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
{
Arc::make_mut(e).appearance = app.into();
carried.onto(Arc::make_mut(e));
}
}
let mut merged = Beside::replacing(d, roots);
@@ -191,6 +218,13 @@ pub(super) fn lp_release(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&ids) }))
}
/// Does face or edge `n` draw on the canvas: it is shown, its opacity is above 0, and a fill or
/// stroke row of its appearance draws (see [`Appearance::has_drawn_fill`] and
/// [`Appearance::has_drawn_stroke`])?
fn draws(n: &Node) -> bool {
n.visible && n.opacity > 0.0 && (n.appearance.has_drawn_fill() || n.appearance.has_drawn_stroke())
}
pub(super) fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
let targets = live_targets(s, p)?;
s.edit("Expand Live Paint", |d, sel| {
@@ -205,8 +239,7 @@ pub(super) fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(ch) = n.children_mut() {
ch.retain(|c| match c.name.as_deref() {
Some(SOURCES_NAME) => false,
Some(FACE_NAME) => !c.appearance.fill_paint().is_none(),
Some(EDGE_NAME) => !c.appearance.stroke_paint().is_none() && c.appearance.stroke_width() > 0.0,
Some(FACE_NAME | EDGE_NAME) => draws(c),
_ => true,
});
}
+6 -4
View File
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Invert Colors",
["Edit", "Edit Colors"],
None,
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
has_selection,
|s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert)
),
@@ -513,14 +513,16 @@ pub(crate) fn edit_if_changed<T>(s: &mut Session, label: &str, f: impl FnOnce(&D
}
/// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed
/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn
/// again.
/// on the graph series marks and graph labels among `roots` and inside them, so the marks and every label of a kind keep
/// their new paint when the graph is drawn again.
pub(crate) fn keeping_series_paint(d: &mut Document, roots: &[NodeId], recolor: impl FnOnce(&mut Document) -> Result<usize>) -> Result<usize> {
let marks = super::graph::series_marks_in(d, roots);
let before = (!marks.is_empty()).then(|| d.clone());
let labels = super::graph::labels_in(d, roots);
let before = (!marks.is_empty() || !labels.is_empty()).then(|| d.clone());
let n = recolor(d)?;
if let Some(before) = before.filter(|_| n > 0) {
super::appearance::capture_series_changes(&before, d, &marks);
super::graph::capture_label_changes(&before, d, &labels, true);
}
Ok(n)
}
@@ -478,6 +478,75 @@ fn an_opened_file_measures_its_type_in_its_own_composite_fonts() {
assert_eq!(actual.cached_bounds, Some(expected));
}
/// The label of dimension `id` in `doc`.
fn label(doc: &vectorcraft_doc::Document, id: u64) -> vectorcraft_doc::TextObject {
let node = doc.node(NodeId(id)).unwrap();
node.children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).unwrap()
}
/// The center of label `t`'s measured box on the page.
fn label_center(t: &vectorcraft_doc::TextObject) -> vectorcraft_geom::Point {
t.xf * t.cached_bounds.unwrap().center()
}
/// Whether every character of label `t` is filled in CMYK.
fn label_in_cmyk(t: &vectorcraft_doc::TextObject) -> bool {
!t.runs.is_empty() && t.runs.iter().all(|r| matches!(r.style.fill.color(), Some(vectorcraft_color::Color::Cmyk { .. })))
}
/// The native file of a CMYK document whose composite font `name` sets Roman at full size, with a
/// dimension whose label is set in it and whose label's characters are saved in RGB, so opening
/// the file draws the dimension again → (the file, the dimension's id, the center of its label as
/// drawn in the document's own composite font).
fn dimension_file(name: &str) -> (Vec<u8>, u64, vectorcraft_geom::Point) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400, "colorMode": "cmyk"})).unwrap();
let mut full = font(name);
full["parts"]["roman"]["size"] = json!(100);
s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
let line = s.execute("shape.line", &json!({"x1": 100, "y1": 100, "x2": 172, "y2": 100})).unwrap()["id"].as_u64().unwrap();
let id = s
.execute(
"dimension.create",
&json!({"source": {"kind": "segment", "edge": {"node": line, "subpath": 0, "segment": 0}},
"options": {"units": "in", "precision": 2, "fontFamily": name, "fontSize": 20}}),
)
.unwrap()["id"]
.as_u64()
.unwrap();
let mut doc = (*s.doc().unwrap().doc).clone();
let drawn = label(&doc, id);
assert!(label_in_cmyk(&drawn));
let center = label_center(&drawn);
let mut label_id = None;
doc.node(NodeId(id)).unwrap().walk(&mut |n| {
if matches!(n.kind, NodeKind::Text(_)) {
label_id = Some(n.id);
}
});
if let Some(NodeKind::Text(t)) = doc.node_mut(label_id.unwrap()).map(|n| &mut n.kind) {
for r in &mut t.runs {
r.style.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 0));
}
}
(vectorcraft_format::save_file(&doc), id, center)
}
/// A file whose dimensions are drawn again when it opens (their art is not in the document's color
/// mode) draws their labels in its own composite fonts, not in those of the front document, which
/// defines the label's font name with Roman at half size: each label stays centered on its anchor.
#[test]
fn an_opened_file_draws_its_dimension_labels_in_its_own_composite_fonts() {
let (bytes, id, center) = dimension_file("CF Dimension");
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Dimension")]})).unwrap();
let file = crate::cmd::fileio::read_for_open("dimension.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
crate::cmd::fileio::open_read(&mut s, file).unwrap();
let opened = label(&s.doc().unwrap().doc, id);
assert!(label_in_cmyk(&opened));
assert!((label_center(&opened) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&opened));
}
/// Threaded type read from a file flows through its frames in the file's own composite fonts, when
/// no open document defines them.
#[test]
@@ -536,6 +605,40 @@ fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() {
assert_eq!(actual.cached_bounds, Some(expected));
}
/// A document replaced in front (File › Revert) whose dimensions are drawn again draws their labels
/// in its new composite fonts, not in those it had before.
#[test]
fn a_document_replaced_in_front_draws_its_dimension_labels_in_its_own_composite_fonts() {
let (bytes, id, center) = dimension_file("CF Reverted");
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Reverted")]})).unwrap();
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
let replaced = label(&s.doc().unwrap().doc, id);
assert!(label_in_cmyk(&replaced));
assert!((label_center(&replaced) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&replaced));
}
/// A document replaced in the back draws its dimension labels in its own composite fonts, while the
/// front document keeps its definition of the same name registered; the labels stay where they are
/// when the document comes to the front.
#[test]
fn a_document_replaced_in_the_back_draws_its_dimension_labels_in_its_own_composite_fonts() {
let (bytes, id, center) = dimension_file("CF Back Dimension");
let mut s = session();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Dimension")]})).unwrap();
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
assert_eq!(s.active_index(), Some(1));
let front = vectorcraft_text::FontDb::global().composite_font("CF Back Dimension").unwrap();
assert_eq!(front.parts.get(vectorcraft_doc::CharClass::Roman).size, 50.0);
let replaced = label(&s.documents()[0].doc, id);
assert!(label_in_cmyk(&replaced));
assert!((label_center(&replaced) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&replaced));
assert!(s.set_active(0));
let shown = label(&s.doc().unwrap().doc, id);
assert!((label_center(&shown) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&shown));
}
/// The type of document `index` with id `id`, wherever its tab is.
fn text_in(s: &Session, index: usize, id: u64) -> vectorcraft_doc::TextObject {
match &s.documents()[index].doc.node(NodeId(id)).unwrap().kind {
+6 -1
View File
@@ -495,7 +495,8 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
/// Redraw the dimensions of an opened document whose art isn't in its colour mode: files saved
/// before dimensions followed the colour mode have RGB dimensions in CMYK documents. A dimension
/// that can't be redrawn keeps the art it has.
/// that can't be redrawn keeps the art it has. The labels are measured in the document's own
/// composite fonts, also when another document's definitions of the same names are registered.
pub(crate) fn conform(doc: &mut Document) {
if !doc.dimensions {
return;
@@ -521,6 +522,10 @@ pub(crate) fn conform(doc: &mut Document) {
}
}
});
if stale.is_empty() {
return;
}
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts);
for (id, d) in stale {
// An error leaves this dimension as it was; the others are still redrawn.
let _ = replace(doc, id, d);
+3 -71
View File
@@ -344,44 +344,9 @@ fn spot_swatches(d: &Document) -> HashSet<&str> {
first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect()
}
/// Which of gradient `g`'s stops color some part of it that draws, by the stretches
/// [`vectorcraft_color::Gradient::sample`] mixes in, for stops in any order: the pair of stop `k`
/// and stop `k + 1` covers the offsets above every offset of stops `0..=k` up to stop `k + 1`'s,
/// and both its stops count when that stretch has positive width and either stop is above opacity
/// 0. The first stop also counts when it is above opacity 0, since it colors the offsets up to its
/// own, and so does the last stop, since it colors the offsets above every stop's. Any other stop,
/// opaque or not, counts only through a pair: a stop out of order whose pairs have no width
/// (another stop before it reaches past both ends) colors nothing. A freeform gradient draws from
/// its points, so each of its stops counts while any stop is above opacity 0.
fn drawn_stops(g: &vectorcraft_color::Gradient) -> Vec<bool> {
let stops = &g.stops;
if g.kind == vectorcraft_color::GradientKind::Freeform {
let any = stops.iter().any(|s| s.opacity > 0.0);
return vec![any; stops.len()];
}
let mut drawn = vec![false; stops.len()];
if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) {
*d = first.opacity > 0.0;
}
if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) {
*d |= last.opacity > 0.0;
}
// `f32::max` skips a NaN offset, which the sampler never picks either.
let mut reach = f32::NEG_INFINITY;
for k in 0..stops.len().saturating_sub(1) {
reach = reach.max(stops[k].offset);
let (a, b) = (&stops[k], &stops[k + 1]);
if b.offset > reach && (a.opacity > 0.0 || b.opacity > 0.0) {
drawn[k] = true;
drawn[k + 1] = true;
}
}
drawn
}
/// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is
/// linked to, or those a gradient's drawn stops ([`drawn_stops`]) are linked to, each once, in the
/// order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a
/// linked to, or those a gradient's drawn stops ([`vectorcraft_color::Gradient::drawn_stops`]) are
/// linked to, each once, in the order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a
/// gradient at an opacity of 0 prints none, and a color that only looks like a spot color is not
/// linked to one.
fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> {
@@ -398,7 +363,7 @@ fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> {
match p {
Paint::Solid { swatch, tint, .. } => add(swatch, *tint),
Paint::Gradient(g) => {
let drawn = drawn_stops(&g.gradient);
let drawn = g.gradient.drawn_stops();
g.gradient.stops.iter().zip(drawn).filter(|&(_, d)| d).for_each(|(s, _)| add(&s.swatch, s.tint));
}
_ => {}
@@ -1662,39 +1627,6 @@ mod tests {
assert!(!["Far", "Edge", "Seam"].iter().any(|n| text.contains(n)), "{text}");
}
/// A gradient's stops print where they color a stretch that draws, for stops in any order: the
/// sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it, so an
/// out-of-order stop's pair can have no width.
#[test]
fn drawn_stops_follow_the_sampled_stretches() {
use vectorcraft_color::{Color, Gradient, GradientKind, GradientStop};
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) };
let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops);
// In order: the clear stop next to the opaque one, not the one after it.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)])), vec![true, true, false]);
// Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with
// the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)])), vec![false, false, true, true]);
// An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the
// pairs next to the stop at 0.3 have no width and it colors nothing.
let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]);
assert_eq!(drawn_stops(&out_of_order(0.0)), vec![false; 5]);
// With the stop at 0.8 opaque, the pairs next to it with width are drawn.
assert_eq!(drawn_stops(&out_of_order(1.0)), vec![true, true, false, false, false]);
// The last stop colors the offsets above every stop's, even when it is out of order.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)])), vec![false, false, true]);
// The first stop colors the offsets up to its own.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)])), vec![true, false, false]);
// One stop, every stop clear, and no stops at all.
assert_eq!(drawn_stops(&g(vec![stop(0.5, 1.0)])), vec![true]);
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(1.0, 0.0)])), vec![false, false]);
assert!(drawn_stops(&g(vec![])).is_empty());
// A freeform gradient draws from its points: its stops count while any is above opacity 0.
let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]);
free.kind = GradientKind::Freeform;
assert_eq!(drawn_stops(&free), vec![true, true, true]);
}
/// Brushes, Spot Color Objects, Pattern Objects and Gradient Objects leave out a hidden object,
/// an object at an opacity of 0 and the objects inside a hidden layer or a group at an opacity of
/// 0, with Selection Only too: their fills, strokes, characters and colorized pixels paint no
+1 -1
View File
@@ -70,7 +70,7 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode,
pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub(crate) use export::with_artboard_fills;
pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated};
pub use export::{export_doc, export_source, unused_option_notes};
pub use export::{export_doc, export_source, selection_doc, unused_option_notes};
use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::is_placeholder;
+1 -1
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Selection…",
["File"],
None,
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them",
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})",
has_selection,
export::export_selection
),
+14 -4
View File
@@ -295,8 +295,14 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C
/// ([`isolated`]), with one artboard: their crop. With `text`, the objects alone on one layer, so a
/// text export holds the type of the selected objects and not that of a clipping path around them.
fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> {
let ids = selected_artwork(s)?;
let doc = &s.doc()?.doc;
selection_doc(s.doc()?, cmd, text)
}
/// The document an Export Selection of `st` writes and its crop, as `document.exportSelection`
/// writes it ([`selection`]); `cmd` names the command in the error when nothing selected draws.
pub fn selection_doc(st: &DocState, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> {
let ids = artwork_of(st);
let doc = &st.doc;
let (mut d, bounds) = isolated(doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))?;
if text {
let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
@@ -312,12 +318,16 @@ fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vector
/// layers are guides and are left out. With an object of a dynamic symbol instance's art selected,
/// the instance is exported whole.
pub(crate) fn selected_artwork(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
Ok(artwork_of(s.doc()?))
}
/// [`selected_artwork`] of document `st`.
fn artwork_of(st: &DocState) -> Vec<NodeId> {
let ids = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc));
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
// On a template layer or sublayer, at any depth.
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
Ok(ids.into_iter().filter(|id| !on_template(*id)).collect())
ids.into_iter().filter(|id| !on_template(*id)).collect()
}
/// The crop of an export of objects `ids` of `doc` alone ([`isolated`]): the bounds the renderer
+16
View File
@@ -186,6 +186,22 @@ fn planar_tiffs_read_as_their_chunky_versions() {
}
}
/// A 16-bit CMYK TIFF with alpha places with its alpha rounded from 16 bits, planar as chunky.
#[test]
fn a_16_bit_cmyk_tiff_with_alpha_places_at_16_bits() {
let none = vec![0u16; 6];
let cyan = vec![129u16, 0, 300, 0, 128, 65535];
let alpha = vec![65535u16, 129, 386, 0, 256, 65535];
for extra in [1u16, 2] {
let channels = [cyan.clone(), none.clone(), none.clone(), none.clone(), alpha.clone()];
let chunky = kept(&format!("extra samples {extra}, chunky"), &hand_tiff(5, 16, &[extra], false, &channels));
let planar = kept(&format!("extra samples {extra}, planar"), &hand_tiff(5, 16, &[extra], true, &channels));
assert_eq!(planar, chunky, "extra samples {extra}: planar reads as chunky");
let alphas: Vec<u8> = chunky.1.iter().skip(3).step_by(4).copied().collect();
assert_eq!(alphas, [255, 1, 2, 0, 1, 255], "extra samples {extra}: the alpha, rounded from 16 bits");
}
}
/// A planar TIFF whose header claims more strips than the decoder can count (here 2^31 + 1 rows
/// a strip each, times 5 planes) is refused as too large to draw, without a panic.
#[test]
+1 -1
View File
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Freeform Point",
[],
None,
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {index}",
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind; the points on a label's characters are not stored → {index}",
has_doc,
add_point
),
+2 -2
View File
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind",
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without its placement, fitted to each label",
has_doc,
edit_gradient
),
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient Vector",
[],
None,
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label",
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without the placement this command sets, fitted to each label",
has_doc,
set_gradient_geom
),
+3 -3
View File
@@ -45,9 +45,9 @@ use generate::generate;
#[cfg(test)]
use series::default_series_color;
pub(crate) use series::{
LabelTextChange, capture_label_anti_alias, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles,
capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art,
label_object_opacity_differs, paint_targets, series_group_marks, series_group_marks_where, series_groups_among, series_marks_in,
LabelTextChange, capture_label_anti_alias, capture_label_changes, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows,
capture_label_styles, capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art,
label_object_opacity_differs, labels_in, paint_targets, series_group_marks, series_group_marks_where, series_groups_among, series_marks_in,
series_object_opacity_differs, shown_paint,
};
+2 -3
View File
@@ -6,7 +6,7 @@ use super::labels::{
recolour_text, runs, shadow_of,
};
use super::markers::{MARKER_BUDGET, marker_cost, marker_designs};
use super::series::{series_marks, with_series_gradients, with_series_group_opacity, with_series_opacity};
use super::series::{picks_legend_fill, series_marks, with_series_gradients, with_series_group_opacity, with_series_opacity};
use super::{
Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes,
};
@@ -397,8 +397,7 @@ impl<'a> Plot<'a> {
let at = ring.centre + dir(mid) * (ring.hole + (ring.rad - ring.hole) * 0.62);
let t = self.b.label(Role::Legend, Point::new(at.x, at.y + self.ls * 0.35), &label, Justify::Center, true);
// White on a dark wedge, unless the legend labels were given a colour of their own.
let own_colour = self.g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK));
self.wedge_labels.push(if dark && !own_colour { recolour_text(&t, Color::WHITE) } else { t });
self.wedge_labels.push(if dark && picks_legend_fill(self.g) { recolour_text(&t, Color::WHITE) } else { t });
}
/// The pie's category name.
+155 -10
View File
@@ -696,20 +696,27 @@ pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], metho
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool {
let mut changed = false;
for (graph, role) in label_kinds(doc, ids) {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
let mut style = label_style(&spec.label_styles, role);
change(&mut style);
let slot = match role {
Role::Value => &mut spec.label_styles.values,
Role::Category => &mut spec.label_styles.categories,
Role::Legend => &mut spec.label_styles.legend,
};
changed |= slot.as_ref() != Some(&style);
*slot = Some(style);
changed |= store_label_style(doc, graph, role, change);
}
changed
}
/// Apply `change` to the style graph `graph` keeps for its labels of kind `role` (or the default one). Returns whether
/// the kept style changed.
fn store_label_style(doc: &mut Document, graph: NodeId, role: Role, change: &dyn Fn(&mut CharStyle)) -> bool {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { return false };
let mut style = label_style(&spec.label_styles, role);
change(&mut style);
let slot = match role {
Role::Value => &mut spec.label_styles.values,
Role::Category => &mut spec.label_styles.categories,
Role::Legend => &mut spec.label_styles.legend,
};
let changed = slot.as_ref() != Some(&style);
*slot = Some(style);
changed
}
/// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels
/// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character
/// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes.
@@ -877,6 +884,144 @@ pub(crate) fn capture_label_text(
Ok(changed)
}
/// The character fill and stroke attributes a kind of graph label keeps when a recolor, or a stroke or gradient edit
/// aimed at the characters, changes them on its labels ([`capture_label_changes`]). Each entry copies one attribute
/// from the first style to the second and returns whether they differed.
const RUN_PAINT: [fn(&CharStyle, &mut CharStyle) -> bool; 8] = [
|a, b| keep(&a.fill, &mut b.fill),
|a, b| keep(&a.stroke, &mut b.stroke),
|a, b| keep(&a.stroke_width, &mut b.stroke_width),
|a, b| keep(&a.stroke_cap, &mut b.stroke_cap),
|a, b| keep(&a.stroke_join, &mut b.stroke_join),
|a, b| keep(&a.stroke_miter_limit, &mut b.stroke_miter_limit),
|a, b| keep(&a.stroke_dash, &mut b.stroke_dash),
|a, b| keep(&a.stroke_align, &mut b.stroke_align),
];
/// Copy `from` to `to`. Returns whether they differed.
fn keep<T: Clone + PartialEq>(from: &T, to: &mut T) -> bool {
let differs = from != to;
if differs {
to.clone_from(from);
}
differs
}
/// The [`RUN_PAINT`] attributes an edit changed on the characters of graph label `now` (`was`: the label before the
/// edit), as the label's style and the indexes of the changed entries. An attribute counts only when every character
/// of the label shows it alike, and the fill only when `fill` is set. A placed gradient is kept without its placement,
/// so it fits each label it lands on. The stroke weight and dashes are kept as the label shows them: the graph draws its
/// labels again without the label's own scale.
fn run_paint_change(was: &Node, now: &Node, fill: bool) -> Option<(CharStyle, Vec<usize>)> {
let (NodeKind::Text(was), NodeKind::Text(now)) = (&was.kind, &now.kind) else { return None };
let first_now = &now.runs.first()?.style;
// Changed: some character showed something else before the edit, whichever run it is in.
let changed: Vec<usize> = RUN_PAINT
.iter()
.enumerate()
.filter(|(i, copy)| {
(fill || *i != 0)
&& now.runs.iter().all(|r| !copy(first_now, &mut r.style.clone()))
&& was.runs.iter().any(|r| copy(first_now, &mut r.style.clone()))
})
.map(|(i, _)| i)
.collect();
if changed.is_empty() {
return None;
}
let unplaced = crate::cmd::gradient::unplaced;
let mut style = first_now.clone();
(style.fill, style.stroke) = (unplaced(&style.fill), unplaced(&style.stroke));
// Character strokes are drawn in text space, scaled by the type's transform.
if let Some(k) = vectorcraft_doc::Scaling::factor(now.xf).filter(|k| k.is_finite() && *k > 1e-12) {
let mut st = style.stroke_layer();
crate::inspect::scale_layer(&mut st, k);
style.set_stroke_layer(&st);
}
Some((style, changed))
}
/// Does graph `g` pick the fill of its legend labels itself, white on a dark wedge and black on the others: a pie with
/// Legends in Wedges whose legend labels keep no fill of their own?
pub(super) fn picks_legend_fill(g: &GraphSpec) -> bool {
g.kind == GraphKind::Pie
&& g.legend
&& g.pie_legend_in_wedges
&& !g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK))
}
/// The graph labels among `roots` and inside them, for an edit that reaches graph labels without naming them.
pub(crate) fn labels_in(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut texts = Vec::new();
for id in roots {
let Some(root) = doc.node(*id) else { continue };
root.walk(&mut |n| {
if matches!(n.kind, NodeKind::Text(_)) {
texts.push(n.id);
}
});
}
// Graph labels are type: without type among `roots`, the graph's labels are not looked up.
if texts.is_empty() {
return texts;
}
let labels = graph_labels(doc);
texts.retain(|id| labels.contains_key(id));
// Nested roots (a graph and one of its labels) reach a label twice.
texts.sort_unstable();
texts.dedup();
texts
}
/// Keep for every label of their kind what an edit changed on graph labels `ids` (`before`: the document before the
/// edit): with `rows`, the object rows ([`capture_label_rows`]) of the labels whose rows changed, and the character fill
/// and stroke attributes ([`RUN_PAINT`]) that changed on all of a label's characters, on the kind's kept style. The
/// frontmost label of a kind sets what that kind keeps, whatever order `ids` are in. On a graph that colors its legend
/// labels itself ([`picks_legend_fill`]), their fill is kept only when the legend labels among `ids` end up with one
/// fill. Called inside the same edit, so they undo together. Returns whether what a graph keeps changed.
pub(crate) fn capture_label_changes(before: &Document, doc: &mut Document, ids: &[NodeId], rows: bool) -> bool {
if !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) {
return false;
}
let labels = graph_labels(doc);
let mut picked: Vec<NodeId> = ids.iter().copied().filter(|id| labels.contains_key(id)).collect();
// A later label's capture replaces an earlier one's: take them in paint order, each once.
picked.sort_by_cached_key(|id| doc.index_path(*id));
picked.dedup();
let picks_fill = |graph: NodeId| doc.node(graph).and_then(|n| n.extras().graph.as_deref()).is_some_and(picks_legend_fill);
// On a pie that colors its legend labels black or white by their wedge, a recolor of the whole pie flips them both
// ways: their fill is kept only when every legend label the edit reached ends up with the same one.
let mut wedge_fills: HashMap<NodeId, Vec<Paint>> = HashMap::new();
for id in &picked {
let (Some(&(graph, Role::Legend)), Some(NodeKind::Text(t))) = (labels.get(id), doc.node(*id).map(|n| &n.kind)) else { continue };
if picks_fill(graph) {
wedge_fills.entry(graph).or_default().extend(t.runs.iter().map(|r| r.style.fill.clone()));
}
}
let one_fill = |graph: NodeId| wedge_fills.get(&graph).is_none_or(|f| f.windows(2).all(|w| w[0] == w[1]));
let mut changed_rows = Vec::new();
let mut characters = Vec::new();
for (id, kind) in picked.iter().filter_map(|id| Some((*id, *labels.get(id)?))) {
let (Some(was), Some(now)) = (before.node(id), doc.node(id)) else { continue };
if rows && label_rows(was) != label_rows(now) {
changed_rows.push(id);
}
let fill = kind.1 != Role::Legend || one_fill(kind.0);
characters.extend(run_paint_change(was, now, fill).map(|change| (kind, change)));
}
let mut changed = capture_label_rows(doc, &changed_rows);
for ((graph, role), (style, entries)) in &characters {
changed |= store_label_style(doc, *graph, *role, &|st| {
for i in entries {
if let Some(copy) = RUN_PAINT.get(*i) {
copy(style, st);
}
}
});
}
changed
}
/// The graphs and kinds of label among `ids`, each once.
fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> {
if ids.is_empty() {
+324
View File
@@ -508,6 +508,89 @@ fn paint_targets_leave_a_graph_designs_copy_out() {
assert_eq!(super::paint_targets(d, &[copy, art, mark]), [mark]);
}
/// The art of a marker design in series 1 of a line graph: the design's copy and a path inside it.
fn marker_design_art() -> (Session, NodeId, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Pill"})).unwrap();
let marker = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].clone();
assert!(matches!(marker.kind, NodeKind::Group { .. }), "the design's art");
let mut art = vec![];
marker.walk(&mut |m| {
if matches!(m.kind, NodeKind::Path { .. }) {
art.push(m.id);
}
});
(s, marker.id, art[0])
}
/// The ids `graphicStyle.list` reports as linked to style `name`.
fn linked_to(s: &mut Session, name: &str) -> Vec<u64> {
let list = s.execute("graphicStyle.list", &json!({})).unwrap();
let style = list["styles"].as_array().unwrap().iter().find(|g| g["name"] == name).unwrap().clone();
let mut ids: Vec<u64> = style["linked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
ids.sort_unstable();
ids
}
/// Graphic styles and the art of a column or marker design in a series (the design's copy and a path inside it):
/// `graphicStyle.new` makes a style from the art without linking it; `graphicStyle.redefine` leaves the art's look as
/// it was and unlinks it, whether the art is linked (as in a document saved before) or the source, while the other
/// linked objects follow the new look.
#[test]
fn graphic_styles_leave_a_graph_designs_art_unlinked() {
let make = |marker: bool| {
if marker {
marker_design_art()
} else {
let (s, _, copy, art, _) = design_art();
(s, copy, art)
}
};
let rows = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().appearance.clone();
let link = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().extras().graphic_style;
let link_directly = |s: &mut Session, ids: &[NodeId], style: u32| {
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
for id in ids {
d.node_mut(*id).unwrap().extras_mut().graphic_style = Some(style);
}
};
let rect = |s: &mut Session| {
NodeId(s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap())
};
for marker in [false, true] {
for source in [0, 1] {
let at = format!("marker {marker}, source {source}");
// New: the style is made from the art, which stays unlinked.
let (mut s, copy, art) = make(marker);
let source = [copy, art][source];
s.execute("graphicStyle.new", &json!({"id": source.0, "name": "Art"})).unwrap();
assert_eq!(link(&s, source), None, "{at}");
assert_eq!(linked_to(&mut s, "Art"), Vec::<u64>::new(), "{at}");
// Redefine from another object: linked art keeps its look and is unlinked; a linked rectangle follows.
let follower = rect(&mut s);
s.execute("graphicStyle.apply", &json!({"name": "Art", "ids": [follower.0]})).unwrap();
let style = link(&s, follower).unwrap();
link_directly(&mut s, &[copy, art], style);
let (copy_was, art_was) = (rows(&s, copy), rows(&s, art));
let (blue_rect, blue) = blue_style(&mut s, "Blue", false);
s.execute("graphicStyle.redefine", &json!({"name": "Art", "id": blue_rect.0})).unwrap();
assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was.clone(), art_was.clone()), "{at}");
assert_eq!((link(&s, copy), link(&s, art)), (None, None), "{at}");
assert_eq!(rows(&s, follower).fill_paint(), blue, "{at}");
assert_eq!(linked_to(&mut s, "Art"), [follower.0.min(blue_rect.0), follower.0.max(blue_rect.0)], "{at}");
// Redefine from the art itself: the art is unlinked and the linked rectangle takes the art's look.
link_directly(&mut s, &[copy, art], style);
s.execute("graphicStyle.redefine", &json!({"name": "Art", "id": source.0})).unwrap();
assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was, art_was), "{at}");
assert_eq!((link(&s, copy), link(&s, art)), (None, None), "{at}");
assert_eq!(linked_to(&mut s, "Art"), [follower.0.min(blue_rect.0), follower.0.max(blue_rect.0)], "{at}");
}
}
}
/// The art of a marker design in a series is refused too.
#[test]
fn effect_commands_refuse_a_marker_designs_art() {
@@ -6172,6 +6255,247 @@ fn a_graphic_style_on_a_label_is_kept_for_its_kind() {
assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#0080ff"), "{looks:?}");
}
/// The character style of the first run of label `text` in group `grp`.
fn label_run_style(s: &Session, id: NodeId, grp: &str, text: &str) -> CharStyle {
let NodeKind::Text(t) = label_node(s, id, grp, text).kind else { panic!("{text} is not type") };
t.runs[0].style.clone()
}
/// A recolor of the whole graph (Invert or Complement on the proxy, an Edit Colors command, a color filter or Recolor
/// Artwork) is kept for the characters and the rows of every kind of label when the graph is drawn again, and undo takes
/// it back with its edit.
#[test]
fn recoloring_a_graph_keeps_its_labels_paint_for_every_kind() {
let cases = [
("paint.invert", json!({})),
("paint.complement", json!({})),
("edit.colors.invert", json!({})),
("edit.colors.toGrayscale", json!({})),
("addon.color.filter", json!({"effect": "addon.color.invert"})),
("recolor.apply", json!({"map": {"#3366cc": "#00ff00", "#ff8000": "#8000ff"}})),
];
every_case(cases, |(cmd, params)| {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
// Characters of every label in a color that every case changes, and an orange fill row on the category labels.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("paint.setFill", &json!({"color": "#3366cc"})).unwrap();
let c = text_id(&s, id, "Axes", "Q2");
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff8000"})).unwrap();
let was = spec(&s, id).label_styles;
let mut p = params.clone();
if *cmd == "recolor.apply" {
// Recolor Artwork works on the selection.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
} else {
p["ids"] = json!([id.0]);
}
s.execute(cmd, &p).unwrap();
let hex = |p: &Paint| p.color().map(|c| c.to_hex()).unwrap_or_default();
let characters = hex(&label_run_style(&s, id, "Axes", "4").fill);
assert_ne!(characters, "#3366cc", "{cmd} recolors the characters");
let row = label_node(&s, id, "Axes", "Q2").appearance.items[0].paint().clone();
assert_ne!(hex(&row), "#ff8000", "{cmd} recolors the row");
let looks = label_looks(&mut s, id);
for t in ["0", "Q1", "2024"] {
assert!(looks.iter().any(|(x, _, fill)| x == t && *fill == characters), "{cmd}: {t}: {looks:?}");
}
let rows = redrawn_label_rows(&mut s, id);
assert_eq!(rows_of(&rows, "Q1").0.first().map(|r| r.1.clone()), Some(hex(&row)), "{cmd}: {rows:?}");
// Undo takes back the recolor and what the graph keeps (the two redraws are undone first).
for _ in 0..3 {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(spec(&s, id).label_styles, was, "{cmd}");
});
}
/// A recolor of one label changes what the graph keeps for that kind of label only.
#[test]
fn recoloring_one_label_keeps_its_paint_for_its_kind_only() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.execute("edit.colors.invert", &json!({"ids": [v.0]})).unwrap();
let looks = label_looks(&mut s, id);
assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#ffffff"), "{looks:?}");
assert!(looks.iter().any(|(t, _, fill)| t == "Q1" && fill == "#000000"), "{looks:?}");
assert!(looks.iter().any(|(t, _, fill)| t == "2024" && fill == "#000000"), "{looks:?}");
let g = spec(&s, id).label_styles;
assert!(g.categories.is_none() && g.legend.is_none(), "{g:?}");
}
/// A recolor that leaves a label's first characters as they were and gives the rest the same fill is kept for its kind.
#[test]
fn a_recolor_that_evens_out_a_labels_characters_is_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let q2 = text_id(&s, id, "Axes", "Q2");
s.select_tool("type", crate::ViewInfo::default()).unwrap();
for (start, end, hex) in [(0, 1, "#ff0000"), (1, 2, "#0000ff")] {
s.set_tool_option("textSelection", &json!({"id": q2.0, "start": start, "end": end}));
s.execute("paint.setFill", &json!({"color": hex})).unwrap();
}
s.select_tool("selection", crate::ViewInfo::default()).unwrap();
assert_eq!(spec(&s, id).label_styles.categories, None, "part of a label's text is not kept");
s.execute("select.set", &json!({"ids": [q2.0]})).unwrap();
s.execute("recolor.apply", &json!({"map": {"#0000ff": "#ff0000"}})).unwrap();
let looks = label_looks(&mut s, id);
assert!(looks.iter().any(|(t, _, fill)| t == "Q1" && fill == "#ff0000"), "{looks:?}");
assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#000000"), "{looks:?}");
}
/// Stroke options set with `stroke.set` and a gradient made with `paint.editGradient` and placed with
/// `paint.setGradientGeom` on a label's characters are kept for every label of its kind, the gradient without its
/// placement.
#[test]
fn stroke_and_gradient_edits_on_a_labels_characters_are_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let category = label_run_style(&s, id, "Axes", "Q1");
let v = text_id(&s, id, "Axes", "4");
s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap();
s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3, "cap": "round"})).unwrap();
s.execute("paint.editGradient", &json!({"ids": [v.0], "kind": "linear", "stroke": false})).unwrap();
let placement = json!({"ids": [v.0], "stroke": false, "start": [100, 100], "end": [200, 100], "aspect": 50});
s.execute("paint.setGradientGeom", &placement).unwrap();
let fill = label_run_style(&s, id, "Axes", "4").fill;
assert!(matches!(&fill, Paint::Gradient(g) if g.geom.is_some()), "the edited label's gradient is placed: {fill:?}");
label_looks(&mut s, id);
let st = label_run_style(&s, id, "Axes", "0");
assert_eq!((st.stroke_width, st.stroke_cap), (3.0, vectorcraft_doc::LineCap::Round));
assert!(matches!(&st.fill, Paint::Gradient(g) if g.geom.is_none()), "{:?}", st.fill);
assert_eq!(label_run_style(&s, id, "Axes", "Q1"), category, "the category labels are left as they were");
}
/// `stroke.set`, `stroke.weightUp` and `stroke.weightDown` given the whole graph are kept for every kind of label.
#[test]
fn stroke_weights_set_on_the_whole_graph_are_kept_for_its_labels() {
let cases = [("stroke.set", json!({"weight": 2}), 2.0), ("stroke.weightUp", json!({}), 2.0), ("stroke.weightDown", json!({}), 0.75)];
every_case(cases, |(cmd, params, weight)| {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("paint.setStroke", &json!({"ids": [id.0], "color": "#ff0000"})).unwrap();
let mut p = params.clone();
p["ids"] = json!([id.0]);
s.execute(cmd, &p).unwrap();
assert_eq!(label_run_style(&s, id, "Axes", "4").stroke_width, *weight, "{cmd}");
label_looks(&mut s, id);
for (grp, t) in [("Axes", "0"), ("Axes", "Q1"), ("Legend", "2024")] {
assert_eq!(label_run_style(&s, id, grp, t).stroke_width, *weight, "{cmd}: {t}");
}
});
}
/// A stroke weight set on a scaled label is kept as the label shows it, since the graph draws its labels unscaled.
#[test]
fn a_stroke_weight_on_a_scaled_label_is_kept_as_it_shows() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.execute("select.set", &json!({"ids": [v.0]})).unwrap();
s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap();
s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3})).unwrap();
assert_eq!(label_run_style(&s, id, "Axes", "4").stroke_width, 1.5, "drawn at twice its size");
label_looks(&mut s, id);
assert_eq!(label_run_style(&s, id, "Axes", "0").stroke_width, 3.0);
}
/// When the characters of two labels of one kind are recolored differently, the frontmost one sets what the kind
/// keeps, whatever order `ids` are in.
#[test]
fn the_frontmost_label_of_a_kind_sets_what_its_recolor_keeps() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
// Legend labels: the axes tell value and category labels apart by their order, so these are moved instead.
let (a, b) = (text_id(&s, id, "Legend", "2024"), text_id(&s, id, "Legend", "2025"));
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#0000ff"})).unwrap();
// The label made first goes in front, so neither the order of `ids` nor the order the labels were made in decides.
let (front, back) = if a < b { (a, b) } else { (b, a) };
s.execute("select.set", &json!({"ids": [front.0]})).unwrap();
s.execute("object.arrange.bringToFront", &json!({})).unwrap();
let path = |n: NodeId| s.doc().unwrap().doc.index_path(n);
assert!(path(front) > path(back), "{:?} {:?}", path(front), path(back));
let front_fill = if front == a { "#00ffff" } else { "#ffff00" };
s.execute("edit.colors.invert", &json!({"ids": [front.0, back.0]})).unwrap();
let kept = spec(&s, id).label_styles.legend.unwrap().fill;
assert_eq!(kept.color().map(|c| c.to_hex()).as_deref(), Some(front_fill));
}
/// Apply Nearest Swatch and the ink limit of Print Ink Fixes on a label are kept for its kind.
#[test]
fn nearest_swatch_and_ink_limit_on_a_label_are_kept_for_its_kind() {
let cases = [
("addon.color.applyNearestSwatch", json!({"library": "web-safe-216"})),
("addon.color.printFixes", json!({"scope": "selection", "inkLimit": 200})),
];
every_case(cases, |(cmd, params)| {
let mut s = Session::new();
// Print Ink Fixes works on CMYK documents.
s.execute("file.new", &json!({"width": 800, "height": 800, "colorMode": "cmyk"})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.execute("paint.setFill", &json!({"ids": [v.0], "color": {"c": 90, "m": 80, "y": 70, "k": 60}})).unwrap();
let was = label_run_style(&s, id, "Axes", "4").fill;
let mut p = params.clone();
p["ids"] = json!([v.0]);
s.execute(cmd, &p).unwrap();
let now = label_run_style(&s, id, "Axes", "4").fill;
assert_ne!(now, was, "{cmd} changes the fill");
label_looks(&mut s, id);
assert_eq!(label_run_style(&s, id, "Axes", "0").fill, now, "{cmd}");
});
}
/// Swap Fill and Stroke and Default Fill and Stroke on a label are kept for its kind.
#[test]
fn swap_and_default_fill_and_stroke_on_a_label_are_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap();
s.execute("paint.swap", &json!({"ids": [v.0]})).unwrap();
let swapped = label_run_style(&s, id, "Axes", "4");
label_looks(&mut s, id);
let st = label_run_style(&s, id, "Axes", "0");
assert_eq!((&st.fill, &st.stroke), (&swapped.fill, &swapped.stroke));
assert_eq!(st.fill.color().map(|c| c.to_hex()).as_deref(), Some("#ff0000"));
s.execute("paint.default", &json!({"ids": [text_id(&s, id, "Axes", "4").0]})).unwrap();
label_looks(&mut s, id);
let st = label_run_style(&s, id, "Axes", "0");
assert_eq!((st.fill.color().map(|c| c.to_hex()).as_deref(), st.stroke.is_none()), (Some("#000000"), true));
}
/// On a pie with Legends in Wedges, a recolor of the whole graph leaves the legend labels to the graph's black or white.
#[test]
fn a_recolor_leaves_wedge_legend_labels_their_contrast() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",a,b\nP1,1,2");
s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap();
s.execute("edit.colors.invert", &json!({"ids": [id.0]})).unwrap();
assert_eq!(spec(&s, id).label_styles.legend, None);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap();
// The first series' black wedge is white now, so its label is black.
let NodeKind::Text(t) = label_node(&s, id, "Legend", "a").kind else { panic!("a is not type") };
assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()).as_deref(), Some("#000000"));
// A recolor of one legend label gives the kind that label's fill.
s.execute("edit.colors.invert", &json!({"ids": [text_id(&s, id, "Legend", "a").0]})).unwrap();
let kept = spec(&s, id).label_styles.legend.map(|st| st.fill);
assert_eq!(kept.and_then(|f| f.color()).map(|c| c.to_hex()).as_deref(), Some("#ffffff"));
}
#[test]
fn pie_names_and_wedge_labels_keep_their_style() {
let mut s = Session::new();
+104 -27
View File
@@ -667,6 +667,16 @@ enum ClipTo {
File(PathData),
/// It goes into a new clip group of this path, inside the clip group that holds it.
Nest(PathData),
/// Placement Options clip it to `placement` while its DCS file draws it through `file`: it is
/// clipped to `file` as [`ClipTo::File`] clips it, and the clip group holding it is clipped to
/// `placement` as [`ClipTo::Path`] clips an object. With no `file` (the new file lacks the
/// clipping path of the clip group holding it), that group stays as [`ClipTo::Off`] leaves it,
/// clipped to `placement`.
Both { file: Option<PathData>, placement: PathData },
/// Placement Options clip it to `placement` while its DCS file draws it through `file`, and the
/// clip group holding just it is not its old file's: that clip group's clipping path becomes
/// `placement`, and it goes into a new clip group of `file` inside it.
BothInside { file: PathData, placement: PathData },
/// The clipping path of the clip group holding just it goes (a DCS file's clipping path that
/// its new file lacks): the group stays as a plain group of it, with its id, name, visibility
/// and transparency.
@@ -702,10 +712,10 @@ fn same_path(a: &PathData, b: &PathData) -> bool {
}
/// The clip an image placed with `old_xf` takes when it shows `img`, read from a file, placed with
/// `new_xf`: Placement Options' clip `clip` when they clip it, else the clipping path a DCS file
/// draws `img` through. When neither clips it, a clip group holding just it whose clipping path
/// `held` is its old DCS file's (`old_clip`, in the old pixels, under the nonzero rule its clip
/// groups get) goes.
/// `new_xf`: Placement Options' clip `clip` when they clip it, and the clipping path a DCS file
/// draws `img` through, the file's clip group inside the placement's. When neither clips it, a clip
/// group holding just it whose clipping path `held` is its old DCS file's (`old_clip`, in the old
/// pixels, under the nonzero rule its clip groups get) goes.
fn clip_to(
old_xf: Affine,
old_clip: Option<&PathData>,
@@ -714,16 +724,22 @@ fn clip_to(
new_xf: Affine,
held: Option<(&PathData, FillRule)>,
) -> ClipTo {
if let Some(clip) = clip {
return ClipTo::Path(clip_shape(clip));
}
// Whether the clip group holding just the image is the old DCS file's: a clipping path whose
// fill rule was changed in the document is not.
let file_held = match (old_clip, held) {
(Some(old), Some((held, rule))) => rule == FillRule::NonZero && same_path(&old.transformed(old_xf), held),
_ => false,
};
match img.clip.as_ref().map(|path| path.transformed(new_xf)) {
let file = img.clip.as_ref().map(|path| path.transformed(new_xf));
if let Some(clip) = clip {
let placement = clip_shape(clip);
return match file {
Some(file) if held.is_some() && !file_held => ClipTo::BothInside { file, placement },
file if file.is_some() || file_held => ClipTo::Both { file, placement },
_ => ClipTo::Path(placement),
};
}
match file {
// A clipping mask made in the document stays, with the file's clip group inside it.
Some(path) if held.is_some() && !file_held => ClipTo::Nest(path),
Some(path) => ClipTo::File(path),
@@ -752,7 +768,8 @@ impl Nested<'_> {
let first = d.peek_next_id();
self.next = first;
for l in d.layers.iter_mut() {
self.update(l, false);
// Nothing is picked in the layers themselves, so no layer is clipped.
let _ = self.update(l, false);
}
for root in d.symbols.iter_mut().map(|s| &mut s.art).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) {
self.root(root);
@@ -769,20 +786,30 @@ impl Nested<'_> {
/// mask art).
fn root(&mut self, n: &mut Arc<Node>) {
if !(self.pick)(n) {
return self.update(n, true);
if let Some(placement) = self.update(n, true) {
*n = Arc::new(self.clip_group(Arc::clone(n), placement));
}
return;
}
let (new, clip) = (self.make)(n, None);
let mut new = Arc::new(new);
self.update(&mut new, true);
let _ = self.update(&mut new, true);
*n = match clip {
ClipTo::Path(path) | ClipTo::File(path) | ClipTo::Nest(path) => Arc::new(self.clip_group(new, path)),
ClipTo::Both { file, placement } => self.clip_both(new, file, placement),
ClipTo::BothInside { file, placement } => self.clip_both(new, Some(file), placement),
ClipTo::Keep | ClipTo::Off => new,
};
}
/// `n` (outside the layer tree when `nested`) with the picked objects under it remade; it stays
/// the same node when none is there, and only the nodes on the way to one are copied.
fn update(&mut self, n: &mut Arc<Node>, nested: bool) {
/// the same node when none is there, and only the nodes on the way to one are copied. The
/// result is Placement Options' clip that `n` itself, now the clip group of a DCS file's
/// clipping path holding a picked object (or the plain group [`ClipTo::Off`] leaves), takes:
/// the clipping path of the clip group holding just `n` becomes it, else `n` goes into a new
/// clip group of it.
#[must_use]
fn update(&mut self, n: &mut Arc<Node>, nested: bool) -> Option<PathData> {
let mask = n.extras().mask.as_ref().and_then(|m| {
let mut art = Arc::clone(&m.art);
self.root(&mut art);
@@ -794,6 +821,8 @@ impl Nested<'_> {
// The clipping path and object of a plain group holding just the object, which becomes a
// clip group again.
let mut reclipped = None;
// Placement Options' clip for `n` itself.
let mut around = None;
if let Some(children) = n.children() {
// A clip group holding just the object: its clipping path changes.
let clip_path = match (&n.kind, &children[..]) {
@@ -804,9 +833,11 @@ impl Nested<'_> {
for (i, c) in children.iter().enumerate() {
if !(nested && (self.pick)(c)) {
let mut new = Arc::clone(c);
self.update(&mut new, nested);
if !Arc::ptr_eq(&new, c) {
slots.push((i, new));
match (self.update(&mut new, nested), clip_path) {
(None, _) if Arc::ptr_eq(&new, c) => {}
(None, _) => slots.push((i, new)),
(Some(placement), Some(path)) if i == 1 => slots.extend([(0, with_clip(path, placement)), (1, new)]),
(Some(placement), _) => slots.push((i, Arc::new(self.clip_group(new, placement)))),
}
continue;
}
@@ -816,16 +847,10 @@ impl Nested<'_> {
});
let (new, clip) = (self.make)(c, held);
let mut new = Arc::new(new);
self.update(&mut new, true);
let _ = self.update(&mut new, true);
match (clip, clip_path) {
(ClipTo::Keep, _) => slots.push((i, new)),
(ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => {
let mut path = Node::clone(path);
if let NodeKind::Path { path: p, .. } = &mut path.kind {
*p = clip;
}
slots.extend([(0, Arc::new(path)), (1, new)]);
}
(ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => slots.extend([(0, with_clip(path, clip)), (1, new)]),
(ClipTo::File(clip), None) if plain => reclipped = Some((clip, new)),
(ClipTo::Path(clip) | ClipTo::File(clip) | ClipTo::Nest(clip), _) => {
let group = self.clip_group(new, clip);
@@ -833,11 +858,30 @@ impl Nested<'_> {
}
(ClipTo::Off, Some(_)) if i == 1 => declipped = Some(new),
(ClipTo::Off, _) => slots.push((i, new)),
// The file's clip group stays `n`, and the placement's clip goes around it.
(ClipTo::Both { file: Some(file), placement }, Some(path)) if i == 1 => {
slots.extend([(0, with_clip(path, file)), (1, new)]);
around = Some(placement);
}
(ClipTo::Both { file: Some(file), placement }, None) if plain => {
reclipped = Some((file, new));
around = Some(placement);
}
(ClipTo::Both { file: None, placement }, Some(_)) if i == 1 => {
declipped = Some(new);
around = Some(placement);
}
(ClipTo::BothInside { file, placement }, Some(path)) if i == 1 => {
let group = self.clip_group(new, file);
slots.extend([(0, with_clip(path, placement)), (1, Arc::new(group))]);
}
(ClipTo::Both { file, placement }, _) => slots.push((i, self.clip_both(new, file, placement))),
(ClipTo::BothInside { file, placement }, _) => slots.push((i, self.clip_both(new, Some(file), placement))),
}
}
}
if mask.is_none() && slots.is_empty() && declipped.is_none() && reclipped.is_none() {
return;
return None;
}
let reclipped = reclipped.map(|(clip, object)| (Arc::new(self.clipping_path(clip)), object));
let n = Arc::make_mut(n);
@@ -849,14 +893,14 @@ impl Nested<'_> {
if let NodeKind::Group { children, clip } = &mut n.kind {
(*children, *clip) = (vec![path, object], true);
}
return;
return around;
}
// A clip group that stays as a plain group of just the object.
if let Some(object) = declipped {
if let NodeKind::Group { children, clip } = &mut n.kind {
(*children, *clip) = (vec![object], false);
}
return;
return around;
}
if let Some(children) = n.children_mut() {
for (i, c) in slots {
@@ -865,6 +909,17 @@ impl Nested<'_> {
}
}
}
around
}
/// A clip group of `placement` holding `object`, inside a clip group of `file` when there is a
/// `file` ([`ClipTo::Both`] for an object no clip group holds yet).
fn clip_both(&mut self, object: Arc<Node>, file: Option<PathData>, placement: PathData) -> Arc<Node> {
let object = match file {
Some(file) => Arc::new(self.clip_group(object, file)),
None => object,
};
Arc::new(self.clip_group(object, placement))
}
/// A clip group of `object` clipped to `clip`, as [`clip_image_to`] makes one in the layers.
@@ -1035,6 +1090,15 @@ fn clip_file_to(d: &mut Document, id: NodeId, path: PathData) -> Result<()> {
Ok(())
}
/// Clipping path `path` (a node) with its path changed to `clip`.
fn with_clip(path: &Node, clip: PathData) -> Arc<Node> {
let mut path = Node::clone(path);
if let NodeKind::Path { path: p, .. } = &mut path.kind {
*p = clip;
}
Arc::new(path)
}
/// A clipping path `id` of `path`, unpainted.
fn clipping_path(id: NodeId, path: PathData) -> Node {
let none = vectorcraft_color::Paint::None;
@@ -1130,6 +1194,19 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
ClipTo::File(path) => clip_file_to(d, id, path),
ClipTo::Nest(path) => new_clip_group(d, id, path),
ClipTo::Off => unclip(d, id),
ClipTo::Both { file, placement } => {
match file {
Some(file) => clip_file_to(d, id, file)?,
None => unclip(d, id)?,
}
// The file's clip group (or the plain group left of it) takes the placement's clip.
let group = d.parent_of(id).ok_or(EngineError::NoNode(id))?;
clip_image_to(d, group, placement)
}
ClipTo::BothInside { file, placement } => {
clip_image_to(d, id, placement)?;
new_clip_group(d, id, file)
}
})
}
+15 -2
View File
@@ -149,9 +149,22 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
};
let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0)));
let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0");
let radius = number("radius", |r| r >= 0.0, "a radius of 0 or more")?;
// Saved steps ([`Session::replay`]) can hold values a direct call refuses, recorded before these
// checks: a negative radius is 0, a whole side count of 0 or more is brought into 3 to 1000, and
// a fractional or negative one is ignored. A whole number written as 5.0 counts as 5, as in a
// direct call.
let replaying = s.replaying();
let radius = if replaying {
number("radius", |_| true, "a number")?.map(|r| r.max(0.0))
} else {
number("radius", |r| r >= 0.0, "a radius of 0 or more")?
};
// A polygon's side count: a whole number from 3 to 1000.
let sides = number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64);
let sides = if replaying {
number("sides", |_| true, "a number")?.filter(|n| n.fract() == 0.0 && *n >= 0.0).map(|n| n.min(1000.0) as u64)
} else {
number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64)
};
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
+7 -5
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swap Fill and Stroke",
[],
Some("Shift+X"),
"{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight (exceptions: docs/mcp.md)",
"{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight, and on a graph label the swapped paints of its characters for every label of its kind (exceptions: docs/mcp.md)",
has_doc,
swap
),
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Default Fill and Stroke",
[],
Some("D"),
"{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke, and on a graph label the black fill and no stroke of its characters for every label of its kind (exceptions: docs/mcp.md)",
has_doc,
default_paint
),
@@ -96,7 +96,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Invert",
[],
None,
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md); no undo step when no color changes → {changed}",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke, and on a graph label the new color of its characters and rows for every label of its kind (docs/mcp.md); no undo step when no color changes → {changed}",
has_doc,
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
),
@@ -925,7 +925,8 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
}
// The swap writes the paint of the topmost fill and stroke, not their opacity.
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept))
let labels = super::graph::capture_label_changes(&before, d, &ids, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels))
})?;
}
if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 {
@@ -954,7 +955,8 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept))
let labels = super::graph::capture_label_changes(&before, d, &ids, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels))
})?;
}
super::newart::reset(s);
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Recolor Artwork",
["Edit", "Edit Colors"],
None,
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
has_doc,
apply
),
+2 -2
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), 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)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}",
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), 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)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters the stroke options they take → {objects: how many objects took the stroke options}",
has_doc,
stroke_set
),
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Weight Up",
[],
Some("Cmd+Alt+."),
"{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke (docs/mcp.md) → {objects}",
"{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke, and on a graph label the new weight of its characters' stroke or its stroke row for every label of its kind (docs/mcp.md) → {objects}",
has_doc,
|s, p| stroke_step(s, p, true)
),
+12 -7
View File
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Graphic Style",
["Window", "Graphic Styles"],
None,
"{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own stands for the topmost path directly in it; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}",
"{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own stands for the topmost path directly in it; placed gradients are kept relative to its bounds); links the object, unless it is the art of a graph design in a series. Names are made unique → {name}",
has_doc,
style_new
),
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Redefine Graphic Style",
["Window", "Appearance"],
None,
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}",
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. The art of a graph design in a series keeps its look and is unlinked, as the source or as a linked object. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}",
has_doc,
style_redefine
),
@@ -392,8 +392,10 @@ fn style_new(s: &mut Session, p: &Value) -> Result<Value> {
None => d.new_graphic_style_name(),
};
let g = GraphicStyle { id: d.next_graphic_style_id(), ..GraphicStyle::of(name.clone(), captured(n), n) };
// The art of a graph design in a series is not linked: the graph draws it again from the design.
let link = (!super::graph::is_design_art(d, src)).then_some(g.id);
s.edit("New Graphic Style", |d, _| {
set_link(d, &[src], Some(g.id));
set_link(d, &[src], link);
d.graphic_styles.push(g);
Ok(())
})?;
@@ -491,10 +493,12 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
s.edit_named("Redefine Graphic Style “{name}”", &name, false, |d, _| {
let id = d.graphic_style_id(i);
let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look });
// Objects still linked follow the new definition; edited ones are unlinked (they keep
// their look), and the source keeps its own.
// Objects still linked follow the new definition; edited ones are unlinked (they keep their look), and the
// source keeps its own. The art of a graph design in a series is unlinked and keeps its look too, as the
// source or as a linked object: the graph draws it again from the design.
let linked = objects_where(d, |n| n.extras().graphic_style == Some(id) && n.id != src);
let (follow, edited): (Vec<NodeId>, Vec<NodeId>) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old)));
let (follow, edited): (Vec<NodeId>, Vec<NodeId>) =
linked.into_iter().partition(|nid| !super::graph::is_design_art(d, *nid) && d.node(*nid).is_some_and(|n| in_sync(n, &old)));
set_link(d, &edited, None);
// A linked graph series group passes the style to the paths directly inside it, so the graph stores it, and
// goes back to the empty appearance the graph draws it with. The source and the edited paths keep their look.
@@ -509,7 +513,8 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
}
super::appearance::capture_series_writes(&before, d, &follow_marks, (true, true), (true, true), false, true);
super::graph::capture_label_rows(d, &follow_marks);
set_link(d, &[src], Some(id));
let link = (!super::graph::is_design_art(d, src)).then_some(id);
set_link(d, &[src], link);
Ok(())
})?;
Ok(json!({ "name": name }))
+136 -1
View File
@@ -13,7 +13,7 @@ use std::sync::{Arc, LazyLock};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Symbol, SymbolNineSlice};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, Rect, SubPath, shapes};
use super::brushsym::{CURRENT_SYMBOL, natural_size, normalize, place_xf, set_natural_size};
use super::clipboard::Clipboard;
@@ -160,6 +160,7 @@ pub const BUILTIN: &[BuiltinSymbolLibrary] = &[
BuiltinSymbolLibrary { id: "map-markers", name: "Map Markers", make: map_markers },
BuiltinSymbolLibrary { id: "inventory-icons", name: "Inventory Icons", make: inventory_icons },
BuiltinSymbolLibrary { id: "skill-icons", name: "Skill Icons", make: skill_icons },
BuiltinSymbolLibrary { id: "weather-icons", name: "Weather Icons", make: weather_icons },
];
/// Built-in library `id`, made once.
@@ -358,6 +359,140 @@ fn skill_icons() -> SymbolLibrary {
SymbolLibrary { name: "Skill Icons".into(), doc }
}
/// Weather Icons: sun, moon, cloud, rain, thunder and snow for weather/status art (original, no 9-slice).
fn weather_icons() -> SymbolLibrary {
// 1. Sun: Central circular disk with radial rays.
let mut sun_subpaths = Vec::new();
sun_subpaths.extend(shapes::ellipse(Rect::new(-8.5, -8.5, 8.5, 8.5)).subpaths);
for i in 0..8 {
let angle = (i as f64) * std::f64::consts::PI / 4.0;
let (sin, cos) = angle.sin_cos();
let r_in = 11.5;
let r_out = 18.5;
let w = 1.5;
let p1 = Point::new(r_in * cos - w * sin, r_in * sin + w * cos);
let tip = Point::new(r_out * cos, r_out * sin);
let p2 = Point::new(r_in * cos + w * sin, r_in * sin - w * cos);
sun_subpaths.push(SubPath::polyline(&[p1, tip, p2], true));
}
let sun = PathData::new(sun_subpaths);
// 2. Moon: Crescent moon shape.
let moon_anchors = vec![
Anchor { p: Point::new(3.0, -17.0), h_in: Point::new(-2.0, -10.0), h_out: Point::new(-8.0, -17.0), kind: AnchorKind::Corner },
Anchor { p: Point::new(-16.0, 0.0), h_in: Point::new(-16.0, -9.0), h_out: Point::new(-16.0, 9.0), kind: AnchorKind::Smooth },
Anchor { p: Point::new(5.0, 16.0), h_in: Point::new(-6.0, 16.0), h_out: Point::new(0.0, 8.0), kind: AnchorKind::Corner },
Anchor { p: Point::new(-4.0, -1.0), h_in: Point::new(-4.0, 6.0), h_out: Point::new(-4.0, -8.0), kind: AnchorKind::Smooth },
];
let moon = PathData::single(SubPath { anchors: moon_anchors, closed: true });
// Helper for cloud contour
let cloud_subpath = |offset_y: f64, scale: f64| -> SubPath {
let mut pts = Vec::new();
let base_y = 8.0 * scale + offset_y;
pts.push(Point::new(9.0 * scale, base_y));
pts.push(Point::new(-9.0 * scale, base_y));
let c1 = Point::new(-9.0 * scale, 2.0 * scale + offset_y);
let r1 = 6.0 * scale;
let steps_lobe = 8;
for i in 1..=steps_lobe {
let a = std::f64::consts::FRAC_PI_2 + (i as f64) * std::f64::consts::PI / (steps_lobe as f64);
pts.push(Point::new(c1.x + r1 * a.cos(), c1.y + r1 * a.sin()));
}
let c2 = Point::new(0.0, -3.0 * scale + offset_y);
let r2 = 9.0 * scale;
let a_start = -2.973;
let a_end = -0.169;
let steps_top = 10;
for i in 1..=steps_top {
let a = a_start + (i as f64) * (a_end - a_start) / (steps_top as f64);
pts.push(Point::new(c2.x + r2 * a.cos(), c2.y + r2 * a.sin()));
}
let c3 = Point::new(9.0 * scale, 2.0 * scale + offset_y);
let r3 = 6.0 * scale;
for i in 1..steps_lobe {
let a = -std::f64::consts::FRAC_PI_2 + (i as f64) * std::f64::consts::PI / (steps_lobe as f64);
pts.push(Point::new(c3.x + r3 * a.cos(), c3.y + r3 * a.sin()));
}
SubPath::polyline(&pts, true)
};
// 3. Cloud: Merged circular lobes with a flat base.
let cloud = PathData::single(cloud_subpath(0.0, 1.0));
// 4. Rain: Cloud outline with angled falling droplet/dash lines.
let mut rain_subpaths = vec![cloud_subpath(-4.0, 0.9)];
let drops = [(-6.0, 6.0), (0.0, 8.0), (6.0, 6.0)];
for &(x0, y0) in &drops {
let (dx, dy) = (-2.0, 6.0);
let w = 1.0;
let d = [Point::new(x0 + w, y0), Point::new(x0 + dx + w, y0 + dy), Point::new(x0 + dx - w, y0 + dy), Point::new(x0 - w, y0)];
rain_subpaths.push(SubPath::polyline(&d, true));
}
let rain = PathData::new(rain_subpaths);
// 5. Thunder: Angular zigzag lightning bolt polygon.
let bolt = [(3.0, -18.0), (-8.0, -1.0), (-1.0, -1.0), (-5.0, 18.0), (8.0, 1.0), (1.0, 1.0)];
let bolt_pts: Vec<Point> = bolt.iter().map(|&(x, y)| Point::new(x, y)).collect();
let thunder = PathData::single(SubPath::polyline(&bolt_pts, true));
// 6. Snow: Symmetrical snowflake with hexagonal cross and branching ticks.
let mut snow_pts = Vec::new();
let r_hub = 3.5;
for k in 0..6 {
let angle = (k as f64) * std::f64::consts::PI / 3.0;
let (u_sin, u_cos) = angle.sin_cos();
let u = Point::new(u_cos, u_sin);
let v = Point::new(-u_sin, u_cos);
let to_pt = |du: f64, dv: f64| Point::new(du * u.x + dv * v.x, du * u.y + dv * v.y);
let valley_angle = angle - std::f64::consts::PI / 6.0;
snow_pts.push(Point::new(r_hub * valley_angle.cos(), r_hub * valley_angle.sin()));
snow_pts.push(to_pt(5.0, -1.0));
snow_pts.push(to_pt(8.5, -1.0));
snow_pts.push(to_pt(10.0, -3.0));
snow_pts.push(to_pt(11.5, -1.0));
snow_pts.push(to_pt(15.5, -1.0));
snow_pts.push(to_pt(15.5, -2.5));
snow_pts.push(to_pt(18.0, 0.0));
snow_pts.push(to_pt(15.5, 2.5));
snow_pts.push(to_pt(15.5, 1.0));
snow_pts.push(to_pt(11.5, 1.0));
snow_pts.push(to_pt(10.0, 3.0));
snow_pts.push(to_pt(8.5, 1.0));
snow_pts.push(to_pt(5.0, 1.0));
}
let snow = PathData::single(SubPath::polyline(&snow_pts, true));
let icons = [
("Sun", sun, "#f39c12"),
("Moon", moon, "#5c6bc0"),
("Cloud", cloud, "#78909c"),
("Rain", rain, "#3498db"),
("Thunder", thunder, "#f1c40f"),
("Snow", snow, "#29b6f6"),
];
let mut doc = Document::new(100.0, 100.0);
for (name, path, hex) in icons {
let fill = Color::from_hex(hex).unwrap_or(Color::BLACK);
let edge = fill.lerp(&Color::BLACK, 0.35);
let id = doc.alloc_id();
let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25));
let Ok((art, size, _)) = normalize(art) else { continue };
doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) });
set_natural_size(&mut doc, name, Some(size));
}
doc.title = "Weather Icons".into();
SymbolLibrary { name: "Weather Icons".into(), doc }
}
/// An instance of symbol `name` (what the resource gathering follows).
fn instance(name: &str) -> Node {
Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -222,6 +222,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
("warichuAlign", json!(v.warichu_align)),
("akiLeft", json!(v.aki_left)),
("akiRight", json!(v.aki_right)),
("tsume", json!(v.tsume)),
("standardVerticalRoman", json!(v.standard_vertical_roman)),
("noBreak", json!(v.no_break)),
("digits", json!(v.digits)),
@@ -578,6 +579,10 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
}
}
let (key, lo, hi) = ("tsume", 0.0, vectorcraft_doc::text::MAX_TSUME);
if changed(key) && !(lo..=hi).contains(&st.tsume) {
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
}
let lines = vectorcraft_doc::text::WARICHU_LINES;
if changed("warichuLines") && !lines.contains(&st.warichu_lines) {
return Err(bad(&c, format!("`warichuLines` must be a whole number from {} to {}", lines.start(), lines.end())));
@@ -1075,12 +1080,12 @@ fn unused(s: &mut Session, kind: Kind) -> Result<Value> {
/// 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, Insert Aki, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose
/// scales, leading, shifts, Insert Aki, Tsume, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose
/// indents, spacing, Hyphenation or Justification options or tab stops are out of the range
/// `paraStyle.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; 21] = [
const RANGED: [&str; 22] = [
"size",
"h_scale",
"v_scale",
@@ -1088,6 +1093,7 @@ fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
"baseline_shift",
"akiLeft",
"akiRight",
"tsume",
"tcyUpDown",
"tcyLeftRight",
"warichuLines",
+4 -1
View File
@@ -397,6 +397,8 @@ fn character_style_dimensions_are_checked_before_a_definition_changes() {
json!({"tcyLeftRight": -1296.1}),
json!({"akiLeft": -0.1}),
json!({"akiRight": 1000.1}),
json!({"tsume": -0.1}),
json!({"tsume": 100.1}),
] {
let key = attrs.as_object().unwrap().keys().next().unwrap();
for cmd in ["charStyle.edit", "charStyle.setAttrs", "charStyle.new"] {
@@ -419,7 +421,8 @@ fn character_style_dimensions_are_checked_before_a_definition_changes() {
"h_scale": 1.0, "v_scale": 10000.0,
"baseline_shift": -1296.0,
"tcyUpDown": 1296.0, "tcyLeftRight": -1296.0,
"akiLeft": 0.0, "akiRight": 1000.0
"akiLeft": 0.0, "akiRight": 1000.0,
"tsume": 100.0
}}),
)
.unwrap();
@@ -1821,6 +1821,61 @@ fn insert_aki_is_a_character_attribute_set_one_side_at_a_time() {
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step for a refused call");
}
/// Tsume (Character panel, East Asian options): a character attribute in percent, 0 by default
/// and saved only when not 0. Both commands set it in one undo step, a call that gives the value
/// the text already has records none, and a value that is not a number or lies outside 0 to 100 is
/// refused, naming the key, before anything changes; a character style captured from the
/// characters carries it.
#[test]
fn tsume_is_a_character_attribute() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "(日本)"})).unwrap()["id"].as_u64().unwrap();
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.clone(),
_ => panic!("text"),
};
let tsume = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.tsume)).collect::<Vec<_>>();
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert!(saved.get("tsume").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"tsume": 50})).unwrap();
assert_eq!(tsume(&s), [("(日本)".to_string(), 50.0)]);
let undo = s.doc().unwrap().history.undo.len();
s.execute("text.setFormat", &json!({"tsume": 50})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step without a change");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(tsume(&s), [("(日本)".to_string(), 0.0)], "one undo step");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(tsume(&s), [("(日本)".to_string(), 50.0)]);
let paren = "(".len();
s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": paren, "tsume": 100})).unwrap();
assert_eq!(tsume(&s), [("(".to_string(), 100.0), ("日本)".to_string(), 50.0)]);
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert_eq!(saved["tsume"], json!(100.0));
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.tsume)).collect();
assert_eq!(reopened, tsume(&s));
let attrs = super::super::textstyles::char_attrs(&runs(&s)[0].style);
assert_eq!(attrs["tsume"], json!(100.0));
let before = tsume(&s);
let undo = s.doc().unwrap().history.undo.len();
for (cmd, p, want) in [
("text.setFormat", json!({"tsume": "50"}), "`tsume` must be a number"),
("text.setFormat", json!({"tsume": -1}), "`tsume` must be from 0 to 100"),
("text.setRangeStyle", json!({"id": id, "tsume": true}), "`tsume` must be a number"),
("text.setRangeStyle", json!({"id": id, "start": 0, "end": paren, "tsume": 100.5}), "`tsume` must be from 0 to 100"),
] {
let e = s.execute(cmd, &p).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == want), "{cmd} {p}: {e}");
}
assert_eq!(tsume(&s), before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step for a refused call");
}
/// Standard Vertical Roman Alignment (Character panel menu, East Asian options): a character
/// attribute, off by default and saved only when on. Documents written before it read as off; a
/// character style captured from the characters carries it.
+186
View File
@@ -2947,6 +2947,192 @@ fn live_paint_release_and_expand() {
assert!((area(&s, a) - 10000.0).abs() < 1.0);
}
/// Set the appearance rows of the face of Live Paint group `g` at `face` (or the edge nearest
/// `edge`) to `items`, and give it `opacity`; returns that child's id.
fn set_live_part(s: &mut Session, g: NodeId, face: Option<(f64, f64)>, edge: Option<(f64, f64)>, items: Vec<AppearanceItem>, opacity: f32) -> NodeId {
use vectorcraft_tools::builder::{edge_near, face_at};
let n = node(s, g);
let part = match (face, edge) {
(Some((x, y)), _) => face_at(&n, Point::new(x, y)),
(_, Some((x, y))) => edge_near(&n, Point::new(x, y), 0.5),
_ => None,
};
let id = part.unwrap().id;
s.edit("t", |d, _| {
let c = d.node_mut(id).unwrap();
c.appearance.items = items;
c.opacity = opacity;
Ok(())
})
.unwrap();
id
}
/// Expand keeps a face or edge when a fill or stroke row draws it, whatever row is on top: a
/// painted lower row under an unpainted top row is kept, and a part whose only painted row is
/// hidden, transparent or 0 pt wide, or whose own opacity is 0, is dropped.
#[test]
fn live_paint_expand_keeps_the_faces_and_edges_that_draw() {
use vectorcraft_doc::{FillLayer, StrokeLayer};
let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p));
let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w));
let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let hidden = |mut i: AppearanceItem| {
match &mut i {
AppearanceItem::Fill(f) => f.visible = false,
AppearanceItem::Stroke(st) => st.visible = false,
}
i
};
let clear = |mut i: AppearanceItem| {
match &mut i {
AppearanceItem::Fill(f) => f.opacity = 0.0,
AppearanceItem::Stroke(st) => st.opacity = 0.0,
}
i
};
let mut s = session();
let g = live(&mut s);
let lower_fill = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![fill(red()), fill(Paint::None)], 1.0);
let hidden_fill = set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![hidden(fill(red()))], 1.0);
let clear_fill = set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![clear(fill(red()))], 1.0);
let lower_stroke = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke(red(), 2.0), stroke(Paint::None, 1.0)], 1.0);
let hidden_stroke = set_live_part(&mut s, g, None, Some((150.0, 50.0)), vec![hidden(stroke(red(), 2.0))], 1.0);
let thin_stroke = set_live_part(&mut s, g, None, Some((100.0, 50.0)), vec![stroke(red(), 0.0)], 1.0);
let clear_stroke = set_live_part(&mut s, g, None, Some((50.0, 50.0)), vec![clear(stroke(red(), 2.0))], 1.0);
let clear_edge = set_live_part(&mut s, g, None, Some((75.0, 0.0)), vec![stroke(red(), 2.0)], 0.0);
let parts = [lower_fill, hidden_fill, clear_fill, lower_stroke, hidden_stroke, thin_stroke, clear_stroke, clear_edge];
assert_eq!(parts.iter().collect::<std::collections::HashSet<_>>().len(), parts.len());
let before = node(&s, g);
s.execute("livePaint.expand", &json!({})).unwrap();
let n = node(&s, g);
let kept: Vec<NodeId> = n.children().unwrap().iter().map(|c| c.id).collect();
let has = |id: NodeId| kept.contains(&id);
assert!(has(lower_fill) && has(lower_stroke), "a painted lower row draws: {kept:?}");
for id in [hidden_fill, clear_fill, hidden_stroke, thin_stroke, clear_stroke, clear_edge] {
assert!(!has(id), "{id:?} draws nothing: {kept:?}");
}
// The kept parts keep every row.
let rows = |n: &Node, id: NodeId| n.children().unwrap().iter().find(|c| c.id == id).unwrap().appearance.items.clone();
assert_eq!(rows(&n, lower_fill), rows(&before, lower_fill));
assert_eq!(rows(&n, lower_stroke), rows(&before, lower_stroke));
}
/// Merge carries a face's whole appearance when one of its rows draws, and carries nothing from a
/// face that draws nothing.
#[test]
fn live_paint_merge_carries_the_faces_that_draw() {
use vectorcraft_doc::FillLayer;
use vectorcraft_tools::builder::face_at;
let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p));
let green = || Paint::solid(Color::rgb(0.0, 1.0, 0.0));
let mut s = session();
let g = live(&mut s);
let rows = vec![fill(green()), fill(Paint::None)];
set_live_part(&mut s, g, Some((25.0, 50.0)), None, rows.clone(), 1.0);
let mut only_hidden = fill(green());
if let AppearanceItem::Fill(f) = &mut only_hidden {
f.visible = false;
}
set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![only_hidden], 1.0);
let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0);
s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap();
let n = node(&s, g);
assert_eq!(face_at(&n, Point::new(25.0, 50.0)).unwrap().appearance.items, rows, "the drawn lower fill is carried");
let b = face_at(&n, Point::new(125.0, 50.0)).unwrap();
assert_eq!(b.appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), "the face takes its source's fill again");
assert!(b.appearance.has_drawn_fill());
}
/// Merge rebuilds the faces and edges as other paths, so the appearance it carries to them leaves
/// the strokes' pen pressure behind and keeps the rest.
#[test]
fn live_paint_merge_leaves_pen_pressure_behind() {
use vectorcraft_doc::{FillLayer, PressureProfile, StrokeLayer};
use vectorcraft_tools::builder::{edge_near, face_at};
let pressed = || {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
st.pressure = PressureProfile::from_samples([(0.0, 0.2), (0.5, 1.0), (1.0, 0.2)]);
assert!(st.pressure.is_some());
st
};
let green = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0))));
let mut s = session();
let g = live(&mut s);
set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![green.clone(), AppearanceItem::Stroke(pressed())], 1.0);
set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![AppearanceItem::Stroke(pressed())], 1.0);
let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0);
s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap();
let n = node(&s, g);
let mut plain = pressed();
plain.pressure = None;
let face = &face_at(&n, Point::new(75.0, 50.0)).unwrap().appearance.items;
assert_eq!(face, &vec![green, AppearanceItem::Stroke(plain.clone())]);
assert_eq!(edge_near(&n, Point::new(0.0, 50.0), 0.5).unwrap().appearance.items, vec![AppearanceItem::Stroke(plain)]);
}
/// The gradients Merge carries to the rebuilt faces and edges keep their fit on the old outline,
/// lower rows included.
#[test]
fn live_paint_merge_keeps_the_fit_of_carried_gradients() {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
use vectorcraft_doc::{FillLayer, StrokeLayer};
use vectorcraft_tools::builder::{edge_near, face_at};
let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)];
let unplaced = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let gradient = || Paint::Gradient(Box::new(unplaced.clone()));
let mut s = session();
let g = live(&mut s);
let rows = vec![AppearanceItem::Fill(FillLayer::new(gradient())), AppearanceItem::Fill(FillLayer::new(Paint::None))];
let face = set_live_part(&mut s, g, Some((25.0, 50.0)), None, rows, 1.0);
let edge = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![AppearanceItem::Stroke(StrokeLayer::new(gradient(), 2.0))], 1.0);
let before = node(&s, g);
let old = |id: NodeId| before.children().unwrap().iter().find(|c| c.id == id).unwrap().clone();
let (old_face, old_edge) = (old(face), old(edge));
let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0);
s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap();
let n = node(&s, g);
let geom = |items: &[AppearanceItem]| match items[0].paint() {
Paint::Gradient(gp) => gp.geom,
p => panic!("a gradient, not {p:?}"),
};
let face_bounds = old_face.geometric_bounds().unwrap();
let new_face = face_at(&n, Point::new(25.0, 50.0)).unwrap();
assert_eq!(geom(&new_face.appearance.items), Some(unplaced.resolve(face_bounds)), "the lower-row fill keeps its fit");
let AppearanceItem::Stroke(st) = &old_edge.appearance.items[0] else { panic!("a stroke") };
let edge_box = st.paint_bounds(old_edge.geometric_bounds().unwrap());
let new_edge = edge_near(&n, Point::new(0.0, 50.0), 0.5).unwrap();
assert_eq!(geom(&new_edge.appearance.items), Some(unplaced.resolve(edge_box)), "the stroke keeps its fit");
}
/// Merge carries a face's or edge's visibility, opacity and blending mode to the rebuilt part with
/// its appearance: a half-transparent face drawn by a stroke alone stays half transparent, and a
/// hidden edge stays hidden.
#[test]
fn live_paint_merge_keeps_the_visibility_opacity_and_blending_of_carried_parts() {
use vectorcraft_color::BlendMode;
use vectorcraft_doc::StrokeLayer;
use vectorcraft_tools::builder::{edge_near, face_at};
let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 2.0));
let mut s = session();
let g = live(&mut s);
let face = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![stroke()], 0.5);
let edge = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke()], 1.0);
s.edit("t", |d, _| {
d.node_mut(face).unwrap().blend = BlendMode::Multiply;
d.node_mut(edge).unwrap().visible = false;
Ok(())
})
.unwrap();
let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0);
s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap();
let n = node(&s, g);
let f = face_at(&n, Point::new(25.0, 50.0)).unwrap();
assert_eq!((f.appearance.items.clone(), f.opacity, f.blend, f.visible), (vec![stroke()], 0.5, BlendMode::Multiply, true));
let e = edge_near(&n, Point::new(0.0, 50.0), 0.5).unwrap();
assert_eq!((e.appearance.items.clone(), e.visible), (vec![stroke()], false), "the hidden edge stays hidden");
}
fn live_group_only(s: &mut Session) -> NodeId {
let r = s.execute("livePaint.make", &json!({})).unwrap();
NodeId(r["id"].as_u64().unwrap())
+112
View File
@@ -490,6 +490,118 @@ fn a_clipping_mask_of_ones_own_stays_when_the_link_is_updated() {
assert_eq!(mask_width(&s), Some(4.0), "the mask's path is kept");
}
/// [`two_plates`] on a 20 × 20 pt page, its composite drawn through the left half of it (same
/// length, so the plates stay where the header says).
fn two_plates_square() -> Vec<u8> {
let text = String::from_utf8(two_plates()).unwrap();
let text = text.replace("0 0 20 10", "0 0 20 20").replace("20 10 scale", "20 20 scale");
text.replacen("10 10 lineto 0 10 lineto", "10 20 lineto 0 20 lineto", 1).into_bytes()
}
/// The bounds (width, height) of the clipping path of clip group `g`.
fn clip_size(doc: &Document, g: NodeId) -> (f64, f64) {
let NodeKind::Group { children, clip: true } = &doc.node(g).unwrap().kind else { panic!("a clip group: {:?}", doc.node(g).unwrap().kind) };
let b = children[0].geometric_bounds().expect("a clipping path");
(b.width(), b.height())
}
#[test]
fn placement_options_clip_a_dcs_image_inside_its_files_clipping_path() {
let dir = temp_dir("placement-clip");
let path = write(&dir, "pic.eps", &two_plates());
let square = write(&dir, "square.eps", &two_plates_square());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
let id = image_of(&s, &r["ids"][0]).id;
s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap();
let depth = |doc: &Document| std::iter::successors(Some(id), |n| doc.parent_of(*n)).count();
let mut first = None;
// The square file fills the 20 × 10 pt placement and is cut to it; the file's own clipping
// path, the left half of its page, still clips the image inside that, on every relink.
for _ in 0..2 {
s.execute("links.relink", &json!({ "ids": [id.0], "path": square })).unwrap();
let doc = &s.doc().unwrap().doc;
let file = doc.parent_of(id).expect("the file's clip group");
let (w, h) = clip_size(doc, file);
assert!((w - 10.0).abs() < 1e-6 && (h - 20.0).abs() < 1e-6, "the file's clipping path: {w} × {h}");
let placement = doc.parent_of(file).expect("the placement's clip group");
let (w, h) = clip_size(doc, placement);
assert!((w - 20.0).abs() < 1e-6 && (h - 10.0).abs() < 1e-6, "the placement's clip: {w} × {h}");
assert_eq!(*first.get_or_insert(depth(doc)), depth(doc), "no group is added on a later relink");
}
// An update of the file keeps both.
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(first, Some(depth(doc)));
let file = doc.parent_of(id).unwrap();
assert!((clip_size(doc, file).0 - 10.0).abs() < 1e-6, "the file's clipping path");
assert!((clip_size(doc, doc.parent_of(file).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip");
// The file loses its clipping path and gains it again: the same two groups, no more.
let unclipped = String::from_utf8(two_plates_square()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
for _ in 0..2 {
std::fs::write(&square, unclipped.as_bytes()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(first, Some(depth(doc)), "the file's group stays, without its clip");
let plain = doc.parent_of(id).unwrap();
assert!(!matches!(doc.node(plain).unwrap().kind, NodeKind::Group { clip: true, .. }), "the file's group is not clipped");
assert!((clip_size(doc, doc.parent_of(plain).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip stays");
std::fs::write(&square, two_plates_square()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(first, Some(depth(doc)), "the file's clip group again, at the same depth");
let file = doc.parent_of(id).unwrap();
assert!((clip_size(doc, file).0 - 10.0).abs() < 1e-6, "the file's clipping path again");
assert!((clip_size(doc, doc.parent_of(file).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip");
}
}
#[test]
fn placement_options_clip_a_dcs_image_in_a_symbol_inside_its_files_clipping_path() {
let dir = temp_dir("placement-clip-symbol");
let path = write(&dir, "pic.eps", &two_plates());
let square = write(&dir, "square.eps", &two_plates_square());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
s.execute("symbol.new", &json!({ "name": "Pic", "ids": [r["ids"][0]] })).unwrap();
let list = s.execute("links.list", &json!({})).unwrap();
let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().expect("the symbol's image");
let id = row["id"].as_u64().unwrap();
s.execute("links.placementOptions", &json!({"ids": [id], "preserve": "fill", "clip": true})).unwrap();
// The clip groups around the symbol's image, innermost first.
let clips = |s: &Session| {
fn find(n: &Node, id: u64, around: &mut Vec<(f64, f64)>) -> bool {
if n.id.0 == id {
return true;
}
let Some(children) = n.children() else { return false };
if !children.iter().any(|c| find(c, id, around)) {
return false;
}
if let NodeKind::Group { clip: true, .. } = n.kind
&& let Some(b) = children[0].geometric_bounds()
{
around.push((b.width(), b.height()));
}
true
}
let mut around = vec![];
let doc = &s.doc().unwrap().doc;
assert!(find(&doc.symbols.iter().find(|w| w.name == "Pic").expect("the symbol").art, id, &mut around));
around
};
for _ in 0..2 {
s.execute("links.relink", &json!({ "ids": [id], "path": square })).unwrap();
let around = clips(&s);
assert_eq!(around.len(), 2, "the file's clip group inside the placement's: {around:?}");
// In the symbol's own units: the file's path as wide as the placement is tall, half as
// wide as it is tall, and the placement twice as wide as it is tall.
let ((fw, fh), (pw, ph)) = (around[0], around[1]);
assert!((fh - 2.0 * fw).abs() < 1e-6 && (fw - ph).abs() < 1e-6, "the file's clipping path: {around:?}");
assert!((pw - 2.0 * ph).abs() < 1e-6, "the placement's clip: {around:?}");
}
}
#[test]
fn a_dcs_file_takes_the_place_parameters_an_eps_file_takes() {
let dir = temp_dir("params");
+24
View File
@@ -126,6 +126,30 @@ fn bad_sides_radii_and_keys_are_errors() {
assert_eq!(corners(&s, id).0, [0.0; 4]);
}
/// Saved steps play as they did before `sides` and `radius` were checked: a negative radius
/// removes the rounding, a whole side count outside 3 to 1000 is brought into that range, and a
/// fractional or negative side count is ignored.
#[test]
fn saved_steps_with_out_of_range_sides_or_radius_still_play() {
let (mut s, id) = session();
let poly = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 80, "sides": 6})).unwrap()["id"].as_u64().unwrap());
let replay = |s: &mut Session, p: Value| s.replay("object.setLiveShape", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
run(&mut s, json!({"id": id.0, "radius": 6}));
replay(&mut s, json!({"id": id.0, "radius": -4}));
assert_eq!(corners(&s, id).0, [0.0; 4]);
let sides = |s: &Session| match node_path(s, poly).1 {
Some(LiveShape::Polygon { sides, .. }) => sides,
other => panic!("{other:?}"),
};
for (n, want) in [(json!(2), 3), (json!(1001), 1000), (json!(2.5), 1000), (json!(-1), 1000), (json!(5.0), 5)] {
replay(&mut s, json!({"ids": [poly.0], "sides": n}));
assert_eq!(sides(&s), want, "{n}");
}
// A direct call still refuses them.
assert!(s.execute("object.setLiveShape", &json!({"id": id.0, "radius": -4})).is_err());
assert!(s.execute("object.setLiveShape", &json!({"ids": [poly.0], "sides": 2})).is_err());
}
#[test]
fn direct_selected_corners_round_alone_and_stay_selected() {
let (mut s, id) = session();
+31 -1
View File
@@ -84,7 +84,7 @@ fn the_builtin_library_is_original_art_generated_in_code() {
let mut s = Session::new();
let list = run(&mut s, "symbol.libraries", json!({}));
let libs = list["libraries"].as_array().unwrap();
assert_eq!(libs.len(), 5, "{libs:?}");
assert_eq!(libs.len(), 6, "{libs:?}");
assert_eq!((libs[0]["id"].as_str(), libs[0]["category"].as_str()), (Some("basic-shapes"), Some("builtIn")));
assert_eq!(
(libs[1]["id"].as_str(), libs[1]["name"].as_str(), libs[1]["category"].as_str()),
@@ -102,6 +102,10 @@ fn the_builtin_library_is_original_art_generated_in_code() {
(libs[4]["id"].as_str(), libs[4]["name"].as_str(), libs[4]["category"].as_str()),
(Some("skill-icons"), Some("Skill Icons"), Some("builtIn"))
);
assert_eq!(
(libs[5]["id"].as_str(), libs[5]["name"].as_str(), libs[5]["category"].as_str()),
(Some("weather-icons"), Some("Weather Icons"), Some("builtIn"))
);
let lib = run(&mut s, "symbol.library", json!({"library": "basic shapes"}));
let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Circle", "Square", "Rounded Square", "Triangle", "Diamond", "Hexagon", "Star", "Burst", "Arrow"]);
@@ -215,6 +219,32 @@ fn map_markers_symbols_add_without_nine_slice() {
));
}
#[test]
fn weather_icons_library_lists_and_places() {
let mut s = session();
let lib = run(&mut s, "symbol.library", json!({"library": "weather-icons"}));
let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Sun", "Moon", "Cloud", "Rain", "Thunder", "Snow"]);
let mut placed = Vec::new();
for (i, name) in names.iter().enumerate() {
let r = run(
&mut s,
"symbol.addFromLibrary",
json!({"library": "weather-icons", "name": name, "place": true, "x": 100.0 + 80.0 * i as f64, "y": 100.0}),
);
assert_eq!(r["added"], json!([name]));
placed.push(r["placed"].as_u64().unwrap());
}
let d = doc(&s);
for (i, name) in names.iter().enumerate() {
assert_eq!(d.symbol_nine_slice(name), None, "{name} should not carry 9-slice guides");
assert!(matches!(
d.node(NodeId(placed[i])).map(|n| &n.kind),
Some(NodeKind::SymbolInstance { symbol, .. }) if symbol == *name
));
}
}
#[test]
fn a_saved_library_reopens_and_its_symbols_join_another_document_in_one_step() {
let dir = folder("user");
+361
View File
@@ -7358,6 +7358,367 @@ fn an_image_on_hidden_lines_does_not_come_in_beside_its_look() {
assert_eq!(art[0].name.as_deref(), Some("Photo"));
}
/// A 64 × 64 RGB image, every pixel of it different, 128 pt square at (20, 10) in `doc`.
fn speckled_photo(doc: &mut Document) -> Node {
let px = image::RgbImage::from_fn(64, 64, |x, y| image::Rgb([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8]));
image_node(doc, ImageBlob::png(png_of(px.into())), (64, 64), Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), None)
}
/// The sizes `%%BeginData:` gives the images' samples in the program written from `doc`.
fn sample_sizes(doc: &Document) -> Vec<usize> {
let program = export_ai_editing(doc, &opts()).unwrap().program;
String::from_utf8_lossy(&program).lines().filter_map(|l| l.strip_prefix("%%BeginData: ")?.trim().parse().ok()).collect()
}
/// The samples of a 64 × 64 RGB image, as `%%BeginData:` counts them (`XI` and CR LF too).
const SPECKLED_WHOLE: usize = 64 * 64 * 3 + 4;
/// The pieces of an image cut into a raster mesh warp (as Object › Expand leaves a Warp or an
/// envelope on an image) are written as the image whole once, then each as the part of it that its
/// triangle shows: the file holds a little more than the image's pixels rather than a copy for every
/// piece, and it reads back drawing as it did.
#[test]
fn the_pieces_of_a_warped_image_are_written_as_the_parts_they_show() {
// GUARDS: each of a warped image's 200 pieces was written with all of the image's samples.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let NodeKind::Image(im) = &photo.kind else { panic!("not an image") };
let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin());
vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 10, false, false)
});
let sizes = sample_sizes(&doc);
assert_eq!(sizes.len(), 200, "{sizes:?}");
assert_eq!(sizes[0], SPECKLED_WHOLE, "the first piece holds the whole image");
// Each piece holds its cell (two triangles share one) and 2 pixels more around it.
let total: usize = sizes.iter().sum();
assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}");
let back = read_back(&doc);
let st = vectorcraft_testkit::raster::perceptual_diff(
&vectorcraft_testkit::raster::render_artboard(&doc),
&vectorcraft_testkit::raster::render_artboard(&back),
8.0,
);
assert!((st.bad_pixels as f64) < 0.005 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels);
}
/// The pieces of a warped image that Preserve Shape Using Transparency cuts with opacity masks that
/// clip are written as the parts their masks show, in a group without knockout, and the file reads
/// back drawing as it did.
#[test]
fn masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show() {
// GUARDS: each masked piece of a warped image was written with all of the image's samples.
// GUARDS: the pieces' knockout group read back with each piece hiding the pieces below it.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let NodeKind::Image(im) = &photo.kind else { panic!("not an image") };
let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin());
vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, true)
});
assert_eq!(doc.layers[0].children().unwrap()[0].knockout, vectorcraft_doc::Knockout::On);
let sizes = sample_sizes(&doc);
assert_eq!(sizes.len(), 32, "{sizes:?}");
assert_eq!(sizes[0], SPECKLED_WHOLE, "the first piece holds the whole image");
let total: usize = sizes.iter().sum();
assert!(total < 6 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}");
let back = read_back(&doc);
let st = vectorcraft_testkit::raster::perceptual_diff(
&vectorcraft_testkit::raster::render_artboard(&doc),
&vectorcraft_testkit::raster::render_artboard(&back),
8.0,
);
assert!((st.bad_pixels as f64) < 0.005 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels);
}
/// The masked pieces of a warped image whose pixels are half transparent read back drawing as they
/// did, except where neighboring pieces overlap: there the image's alpha draws twice.
#[test]
fn masked_pieces_of_a_translucent_warped_image_read_back_but_for_their_overlaps() {
// GUARDS: the pieces' knockout group read back with each piece hiding the pieces below it
// (8,942 of 20,000 pixels differed).
let doc = photo_doc(|d| {
let px = image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8, 128]));
let photo = image_node(d, ImageBlob::png(png_of(px.into())), (64, 64), Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), None);
let NodeKind::Image(im) = &photo.kind else { panic!("not an image") };
let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin());
vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, true)
});
let back = read_back(&doc);
let st = vectorcraft_testkit::raster::perceptual_diff(
&vectorcraft_testkit::raster::render_artboard(&doc),
&vectorcraft_testkit::raster::render_artboard(&back),
8.0,
);
assert!((st.bad_pixels as f64) < 0.05 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels);
}
/// An image with a live Warp keeps its pixels whole once, and its drawn look's pieces are each the
/// part of them they show: it reads back as the image with its Warp live.
#[test]
fn an_image_with_a_live_warp_writes_its_pixels_whole_once() {
// GUARDS: a live Warp's drawn look wrote all of the image's samples for each of its pieces.
let doc = photo_doc(|d| {
let mut photo = speckled_photo(d);
photo.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into();
photo
});
let sizes = sample_sizes(&doc);
assert!(sizes.len() > 100, "{sizes:?}");
assert_eq!(sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(), 1, "{sizes:?}");
let total: usize = sizes.iter().sum();
assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}");
let back = read_back(&doc);
let art = back.layers[0].children().unwrap();
assert_eq!(art.len(), 1, "{:#?}", lines(&back));
assert!(matches!(art[0].kind, NodeKind::Image(_)), "{:?}", art[0].kind);
assert_eq!(art[0].appearance.effects.len(), 1, "{:?}", art[0].appearance);
}
/// An image cut to a clipping path is written with all of its pixels when it is the first of them
/// written, so releasing the clipping path shows the whole image; a second copy cut to a small part
/// is written as that part, 2 pixels more on each side.
#[test]
fn a_clipped_image_written_first_keeps_all_of_its_pixels() {
let window = |d: &mut Document| {
let photo = speckled_photo(d);
let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0)));
group(d, vec![corner, photo], true)
};
let doc = photo_doc(|d| {
let (a, b) = (window(d), window(d));
group(d, vec![a, b], false)
});
let sizes = sample_sizes(&doc);
assert_eq!(sizes.len(), 2, "{sizes:?}");
assert_eq!(sizes[0], SPECKLED_WHOLE);
// The path shows 10 × 10 pixels.
assert_eq!(sizes[1], 14 * 14 * 3 + 4, "{sizes:?}");
let back = read_back(&doc);
let st = vectorcraft_testkit::raster::perceptual_diff(
&vectorcraft_testkit::raster::render_artboard(&doc),
&vectorcraft_testkit::raster::render_artboard(&back),
8.0,
);
assert!((st.bad_pixels as f64) < 0.002 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels);
}
/// The images in `n` and its members, each as its count of effects, its width and its height.
fn images_in(n: &Node, out: &mut Vec<(usize, u32, u32)>) {
if let NodeKind::Image(im) = &n.kind {
out.push((n.appearance.effects.len(), im.width, im.height));
}
for c in n.children().into_iter().flatten() {
images_in(c, out);
}
}
/// A copy of an image written whole already that its clipping path shows none of is written as one
/// pixel of it.
#[test]
fn a_copy_of_an_image_that_does_not_show_is_one_pixel() {
// GUARDS: a copy whose clipping path missed the image was written with all of its pixels.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0)));
let first = group(d, vec![corner, photo], true);
let photo = speckled_photo(d);
let away = clipping(d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 5.0)));
let second = group(d, vec![away, photo], true);
group(d, vec![first, second], false)
});
assert_eq!(sample_sizes(&doc), [SPECKLED_WHOLE, 3 + 4]);
}
/// A 200 × 100 document of: the speckled image when `first`; a square whose opacity mask's art is
/// the image then six squares; the image in a small clipping group.
fn mask_art_then_clipped_photo(first: bool) -> Document {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
if first {
let photo = speckled_photo(&mut doc);
doc.insert(Some(layer), usize::MAX, photo).unwrap();
}
let mut masked = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::gray(0.5));
// The image, then more objects than the clipping group after the mask holds.
let mut members = vec![speckled_photo(&mut doc)];
for i in 0..6 {
let x = f64::from(i) * 10.0;
members.push(square(&mut doc, Rect::new(x, 0.0, x + 5.0, 5.0), Color::rgb(1.0, 1.0, 1.0)));
}
let art = group(&mut doc, members, false);
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
doc.insert(Some(layer), usize::MAX, masked).unwrap();
let photo = speckled_photo(&mut doc);
let corner = clipping(&mut doc, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0)));
let clipped = group(&mut doc, vec![corner, photo], true);
doc.insert(Some(layer), usize::MAX, clipped).unwrap();
doc
}
/// For each object limit from none to all of `doc`'s objects: whether the mask is written, and the
/// sizes `%%BeginData:` gives the images' samples on `%_` lines (the mask's art) and on the others.
fn samples_within_each_limit(doc: &Document) -> Vec<(usize, bool, Vec<usize>, Vec<usize>)> {
let read = crate::import::objects_read(&export_ai_editing(doc, &opts()).unwrap().program).unwrap();
(0..=read)
.map(|max| {
let text = String::from_utf8_lossy(&export_within(doc, &opts(), max).unwrap().program).replace("\r\n", "\n");
let size = |l: &str| l.strip_prefix("%%BeginData: ")?.trim().parse::<usize>().ok();
let hidden = text.lines().filter_map(|l| size(l.strip_prefix("%_")?)).collect();
let shown = text.lines().filter_map(size).collect();
(max, text.contains("(AI9_Mask)"), hidden, shown)
})
.collect()
}
/// An image whose whole copy was in art left out for the object limit (here an opacity mask's art)
/// is written whole again where it is next written, even cut to a small clipping path: the file
/// always holds all of its pixels once.
#[test]
fn an_image_whose_whole_copy_is_left_out_is_written_whole_again() {
// GUARDS: once the mask's art was left out, the clipped copy was written as the part it shows.
let mut left_out_with_copy = 0;
for (max, mask, hidden, shown) in samples_within_each_limit(&mask_art_then_clipped_photo(false)) {
if hidden.is_empty() && shown.is_empty() {
continue;
}
assert!(hidden.contains(&SPECKLED_WHOLE) || shown.contains(&SPECKLED_WHOLE), "room for {max}: {hidden:?} {shown:?}");
left_out_with_copy += usize::from(!mask);
}
assert!(left_out_with_copy > 0, "no room left the mask out and kept the clipped copy");
}
/// An image written whole, then whole again in an opacity mask's art left out for the object limit,
/// is not written whole a third time: the first copy is kept, and the clipped copy after the mask is
/// the part it shows.
#[test]
fn an_image_written_whole_before_art_left_out_stays_written_whole() {
// GUARDS: the mask's copy took the first copy's place, so giving it back wrote the clipped copy whole.
let mut left_out = 0;
for (max, mask, _, shown) in samples_within_each_limit(&mask_art_then_clipped_photo(true)) {
assert!(shown.iter().filter(|s| **s == SPECKLED_WHOLE).count() <= 1, "room for {max}: {shown:?}");
left_out += usize::from(!mask && shown.len() == 2);
}
assert!(left_out > 0, "no room left the mask out and kept both copies");
}
/// An image with a live Warp in a small clipping group keeps all of its pixels: its drawn look is
/// written first, and neither the look nor the image itself is cut to the clipping path, so it
/// reads back with its Warp live and every pixel it had.
#[test]
fn a_clipped_image_with_a_live_warp_keeps_all_of_its_pixels() {
// GUARDS: the image under a live Warp was cut to the clipping path around it.
let doc = photo_doc(|d| {
let mut photo = speckled_photo(d);
photo.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into();
let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 60.0, 50.0)));
group(d, vec![corner, photo], true)
});
let sizes = sample_sizes(&doc);
assert_eq!(sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(), 1, "the look's first piece: {sizes:?}");
let total: usize = sizes.iter().sum();
assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}");
// The image itself, on `%_` lines.
let program = export_ai_editing(&doc, &opts()).unwrap().program;
let hidden: Vec<usize> =
String::from_utf8_lossy(&program).lines().filter_map(|l| l.strip_prefix("%_%%BeginData: ")?.trim().parse().ok()).collect();
assert_eq!(hidden, [SPECKLED_WHOLE]);
let back = read_back(&doc);
let mut images = vec![];
for n in back.layers[0].children().unwrap() {
images_in(n, &mut images);
}
assert_eq!(images, [(1, 64, 64)], "{:#?}", lines(&back));
}
/// An image cut by an opacity mask whose art has an effect that paints past its path keeps the
/// pixels the effect shows: a copy of an image written whole already is cut to all that its mask
/// paints.
#[test]
fn a_masked_image_keeps_the_pixels_its_mask_effects_show() {
// GUARDS: the copy was cut to the mask art's path, short of what its Offset Path paints.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let corner = clipping(d, shapes::rectangle(Rect::new(20.0, 10.0, 24.0, 14.0)));
let first = group(d, vec![corner, photo], true);
let mut masked = speckled_photo(d);
let mut ap = Appearance::basic(solid(Color::WHITE), Paint::None, 0.0);
ap.effects = vec![fx("path.offsetPath", serde_json::json!({"offset": 30.0}))];
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(70.0, 40.0, 80.0, 50.0)), ap);
masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }));
group(d, vec![first, masked], false)
});
let sizes = sample_sizes(&doc);
assert_eq!(sizes.len(), 2, "{sizes:?}");
assert_eq!(sizes[0], SPECKLED_WHOLE);
// The offset art covers (40, 10) to (110, 80): 35 × 35 pixels of the image.
assert!(sizes[1] >= 35 * 35 * 3, "{sizes:?}");
}
/// The speckled image with an opacity mask that clips it to six small white squares in its corner.
fn photo_masked_to_its_corner(doc: &mut Document) -> Node {
let mut masked = speckled_photo(doc);
let squares = (0..6).map(|i| square(doc, Rect::new(20.0 + f64::from(i) * 5.0, 10.0, 23.0 + f64::from(i) * 5.0, 20.0), Color::WHITE)).collect();
let art = group(doc, squares, false);
masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }));
masked
}
/// A copy of an image written whole already whose opacity mask is left out for the object limit is
/// written whole: without the mask, it shows all of its pixels.
#[test]
fn a_masked_copy_whose_mask_is_left_out_keeps_all_of_its_pixels() {
// GUARDS: the copy was cut to its mask, and the mask then left out, so it read back cropped.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let masked = photo_masked_to_its_corner(d);
group(d, vec![photo, masked], false)
});
let (mut left_out, mut kept) = (0, 0);
for (max, mask, _, shown) in samples_within_each_limit(&doc) {
if shown.len() < 2 {
continue;
}
if mask {
assert!(shown[1] < SPECKLED_WHOLE, "room for {max}: {shown:?}");
kept += 1;
} else {
assert_eq!(shown, [SPECKLED_WHOLE, SPECKLED_WHOLE], "room for {max}");
left_out += 1;
}
}
assert!(left_out > 0 && kept > 0, "{left_out} limits left the mask out, {kept} kept it");
}
/// A copy of an image written whole already whose opacity mask is nested too deep to be written is
/// written whole.
#[test]
fn a_masked_copy_nested_too_deep_for_its_mask_keeps_all_of_its_pixels() {
// GUARDS: the copy was cut to its mask, and the mask then left out for its depth.
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let photo = speckled_photo(&mut doc);
doc.insert(Some(layer), usize::MAX, photo).unwrap();
// Isolated instances are written as their art, each a level deeper: the masked copy is at the limit.
let art = photo_masked_to_its_corner(&mut doc);
doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(art) });
for i in 1..writer::MAX_NEST {
let mut inner = instance(&mut doc, &format!("S{}", i - 1), Affine::IDENTITY);
inner.isolate = true;
doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(inner) });
}
let mut outer = instance(&mut doc, &format!("S{}", writer::MAX_NEST - 1), Affine::IDENTITY);
outer.isolate = true;
doc.insert(Some(layer), usize::MAX, outer).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::DEEP_MASKS), "{:?}", out.warnings);
// The symbols' definitions hold copies too: each copy written with its mask is the part the
// mask shows, and each copy whose mask is left out is whole.
let sizes = sample_sizes(&doc);
let whole = sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count();
let masks = String::from_utf8_lossy(&out.program).matches("(AI9_Mask)").count();
assert_eq!(sizes.len() - whole, masks, "{sizes:?}");
assert!(whole >= 2, "the first copy and a copy whose mask is left out: {sizes:?}");
}
/// [`warped_photo`] linked to `/Users/someone/Job/art/photo.png`, in folder `art` next to the
/// document's.
fn linked_warped_photo(doc: &mut Document, effects: Vec<vectorcraft_doc::Effect>) -> Node {
+83 -12
View File
@@ -281,6 +281,17 @@ pub(super) struct Writer<'a> {
/// How many more envelopes may be written live: as many as the reader checks in one file
/// ([`crate::import::ENVELOPE_CHECKS`]).
envelopes_left: usize,
/// The image read last and its pixels ([`images::Samples::of`], `None` when they can't be
/// read): the pieces of a warped image, which follow one another, read them once.
last_samples: Option<(vectorcraft_doc::ImageBlob, Option<images::Read>)>,
/// The images written whole so far, by the address of their data (which each entry keeps), each
/// with the index of its samples in [`Self::payloads`].
whole_images: HashMap<usize, (Arc<Vec<u8>>, usize)>,
/// The outlines of the clipping groups the art being written is in, in the document (empty in
/// a definition, an opacity mask's art and a brush's art, which are written apart from them).
shown: Vec<vectorcraft_geom::BezPath>,
/// The copy of an image that the last [`Self::image`] wrote as a window, when it did.
windowed: Option<images::Windowed>,
}
impl<'a> Writer<'a> {
@@ -348,6 +359,10 @@ impl<'a> Writer<'a> {
definition_objects: 0,
definition_symbol_nodes: 0,
max_objects: crate::import::MAX_EDITING_OBJECTS,
last_samples: None,
whole_images: HashMap::new(),
shown: vec![],
windowed: None,
}
}
@@ -414,15 +429,21 @@ impl<'a> Writer<'a> {
/// Art written since `c` as `lines` is not used: the objects and symbol art charged since are
/// given back, except those of the definitions written since, and the images `lines` marks
/// are emptied (the others keep their indices).
/// are emptied (the others keep their indices). An image whose whole copy is among them is no
/// longer written whole ([`Self::whole_images`]).
fn give_back(&mut self, c: Charges, lines: &str) {
self.objects = c.objects + (self.definition_objects - c.kept_objects);
self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes);
let mut emptied = HashSet::new();
for index in lines.split(PAYLOAD).skip(1).step_by(2) {
if let Some(p) = index.parse::<usize>().ok().and_then(|i| self.payloads.get_mut(i)) {
if let Some((i, p)) = index.parse::<usize>().ok().and_then(|i| Some((i, self.payloads.get_mut(i)?))) {
*p = Vec::new();
emptied.insert(i);
}
}
if !emptied.is_empty() {
self.whole_images.retain(|_, (_, i)| !emptied.contains(i));
}
}
/// Whether the art of a symbol, `art`, may be written: the objects of the symbol art written
@@ -643,8 +664,17 @@ impl<'a> Writer<'a> {
}
self.effects(n);
self.state(n.locked, !n.visible);
let xy = Xy::of(n);
let mut xy = Xy::of(n);
if masked_warp_pieces(n) {
// The reader gives the pieces no mark, and a knockout group's member then knocks out
// what it paints without its opacity mask: each piece would hide the pieces below it.
// Without knockout, a partly transparent image draws its alpha twice only where the
// pieces overlap.
xy.knockout = Knockout::Off;
}
let start = self.body.len();
// An image's copy written as the part that its opacity mask, among other regions, shows.
let mut mask_window = None;
match &n.kind {
NodeKind::Layer { .. } => {}
NodeKind::Group { children, clip: true } => self.clip_group(children, xy),
@@ -671,7 +701,25 @@ impl<'a> Writer<'a> {
}
NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy),
NodeKind::Text(_) => self.text(n, xy),
NodeKind::Image(im) => self.image(&self.doc.shown_image(n, im), xy),
NodeKind::Image(im) => {
// An opacity mask that clips shows the image within what its art paints only (its
// effects and its symbol instances' art included, as the renderer measures it).
let masked = n
.extras()
.mask
.as_ref()
.filter(|m| m.clip && !m.invert && !m.disabled && self.nest < MAX_NEST)
.and_then(|m| vectorcraft_render::painted_bounds(self.doc, &m.art));
if let Some(b) = masked {
self.shown.push(b.to_path(0.1));
}
self.windowed = None;
self.image(&self.doc.shown_image(n, im), xy);
if masked.is_some() {
self.shown.pop();
mask_window = self.windowed.take();
}
}
NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy),
NodeKind::SymbolInstance { symbol, xf } => {
if !self.open_group("u") {
@@ -737,10 +785,14 @@ impl<'a> Writer<'a> {
// A group's dictionary goes between its end and its state block.
let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start));
self.block_end = usize::MAX;
self.dictionary(n);
let mask_written = self.dictionary(n);
if let Some(b) = block {
self.body.push_str(&b);
}
// Without its opacity mask, the image shows whole.
if let Some(w) = mask_window.filter(|_| !mask_written) {
self.write_whole(w);
}
}
// An envelope's plugin `XP` follows its distorted art, after the art's state block.
if deform {
@@ -773,25 +825,28 @@ impl<'a> Writer<'a> {
/// is bound to variables ([`Self::bound_variables`]; without a name, it gets an XML id and an
/// empty `AIArtName`), has text wrap options ([`Self::text_wrap`]), has an opacity mask
/// (`AI9_Mask`, see [`Self::mask`]), or is a symbol's art (its symbol's entries first, see
/// [`Self::define_symbol`]).
fn dictionary(&mut self, n: &Node) {
/// [`Self::define_symbol`]). False when `n` has an opacity mask that is left out: nested too
/// deep, or its art doesn't fit.
fn dictionary(&mut self, n: &Node) -> bool {
let name = n.name.as_deref().filter(|s| !s.is_empty());
let wrap = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look);
let placed = std::mem::take(&mut self.placed);
let typed = std::mem::take(&mut self.typed);
let bound = self.bound_variables(n, placed, typed);
let mut mask = n.extras().mask.as_deref();
let mut mask_written = true;
// Masks nested too deep are left out (an empty mask would hide the object).
if mask.is_some() && self.nest >= MAX_NEST {
self.warn(DEEP_MASKS);
mask = None;
mask_written = false;
}
let entries = match &self.root_entries {
Some((id, _)) if *id == n.id => self.root_entries.take().map(|(_, e)| e),
_ => None,
};
if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() && wrap.is_none() {
return;
return mask_written;
}
let start = self.body.len();
let has_id = name.is_some() || !bound.is_empty();
@@ -817,13 +872,15 @@ impl<'a> Writer<'a> {
// A mask whose art doesn't fit is left out, and with nothing else in it the dictionary too.
if let Some(m) = mask
&& !self.mask(m)
&& !has_id
&& !has_entries
&& wrap.is_none()
{
return self.body.truncate(start);
if !has_id && !has_entries && wrap.is_none() {
self.body.truncate(start);
return false;
}
mask_written = false;
}
self.body.push_str("%_;\n%_\n");
mask_written
}
/// Text wrap options, as entries of their object's dictionary: the offset in art space's points
@@ -866,6 +923,7 @@ impl<'a> Writer<'a> {
fn mask(&mut self, m: &OpacityMask) -> bool {
let outer = std::mem::take(&mut self.body);
let (state, gs, clip) = (self.state, self.gs, self.clip.take());
let shown_by = std::mem::take(&mut self.shown);
let (charges, bound) = (self.charges(), self.bound.len());
(self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE));
// The canvas draws the art's root even when it is hidden, and the reader puts a hidden object
@@ -905,6 +963,7 @@ impl<'a> Writer<'a> {
self.hidden -= 1;
self.nest -= 1;
let lines = std::mem::replace(&mut self.body, outer);
self.shown = shown_by;
if self.left_out_since(charges) {
// The art isn't written, nor are the variable bindings of its objects.
self.give_back(charges, &lines);
@@ -990,9 +1049,11 @@ impl<'a> Writer<'a> {
}
}
self.clip_paint_part(first, &path, rule, &fills);
self.shown.push(path.to_bezpath());
for c in rest {
self.node(c);
}
self.shown.pop();
self.clip_paint_part(first, &path, rule, &strokes);
self.state(first.locked, !first.visible);
let start = self.body.len();
@@ -1667,6 +1728,16 @@ fn clip_path(n: &Node) -> Option<(PathData, FillRule)> {
path.subpaths.iter().any(|sp| !sp.anchors.is_empty()).then_some((path, rule))
}
/// Is `n` a knockout group of the pieces of a mesh-warped image cut by opacity masks
/// ([`vectorcraft_doc::live::mesh_warp_image`] with Preserve Shape Using Transparency)? Each piece
/// knocks out its triangle ([`Node::knockout_outline`]); read from the data, a piece knocks out all
/// that its image paints instead.
fn masked_warp_pieces(n: &Node) -> bool {
n.knockout == Knockout::On
&& matches!(&n.kind, NodeKind::Group { children, clip: false }
if !children.is_empty() && children.iter().all(|c| c.extras().warp_piece && c.extras().mask.is_some()))
}
/// `text`'s lines on `%_` lines, as a part of the data that readers of the printed form skip (an
/// opacity mask's art, a pattern's tile) has them. An image's samples stay as they are after an
/// `XI` without `%_`, and `%_%%EndData` ends them, as in the files users sent. Samples hidden
+2 -2
View File
@@ -422,7 +422,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas),
self.state.take(),
self.gs.replace(Xy::OPAQUE),
self.clip.take(),
(self.clip.take(), std::mem::take(&mut self.shown)),
(self.block_start, self.block_end),
// The art is the brush's own: not on `%_` lines, in a drawn look or in a symbol that
// first uses the brush.
@@ -459,7 +459,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas),
self.state,
self.gs,
self.clip,
(self.clip, self.shown),
(self.block_start, self.block_end),
(self.hidden, self.in_look, self.in_symbol),
) = saved;
+93 -7
View File
@@ -2,13 +2,14 @@
//! (see [`crate::ai_editing`] for the form).
use std::fmt::Write;
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{FillLayer, ImageBlob, ImageObject, LinkInfo};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Shape};
use super::{Writer, Xy};
use crate::ps;
use crate::{ai_ops, ps};
pub(in crate::ai_editing) const IMAGES: &str = "images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray in the editing data for other apps";
pub(in crate::ai_editing) const IMAGE_DEPTH: &str = "images of 16 bits a channel are written at 8 bits a channel in the editing data for other apps";
@@ -26,8 +27,23 @@ pub(in crate::ai_editing) const PAYLOAD: char = '\u{1}';
/// What starts the line of an image's samples: `XI` and its payload, then `%%EndData`.
pub(super) const SAMPLES_LINE: &str = "XI\u{1}";
/// An image's pixels as the data holds them, and whether it had 16 bits a channel ([`Samples::of`]).
pub(super) type Read = Arc<(Samples, bool)>;
/// A copy of an image written as the part of it that its regions show ([`Writer::image`]).
pub(super) struct Windowed {
/// Where its raster is in the body.
at: std::ops::Range<usize>,
/// The index of its samples in [`Writer::payloads`].
payload: usize,
/// The image's pixels.
samples: Read,
/// What places the image's whole pixel grid in the document.
xf: Affine,
}
/// An image's pixels as the data holds them.
struct Samples {
pub(super) struct Samples {
/// `/DeviceGray`, `/DeviceRGB` or `/DeviceCMYK`.
space: &'static str,
/// The image type of `XI` (§8.1): 1 gray, 3 RGB, 4 CMYK.
@@ -79,6 +95,19 @@ impl Samples {
Some((Samples { space, kind, width, height, color, alpha }, deep))
}
/// The pixels of `window` (`[x0, y0, x1, y1]`, within the image) alone.
fn window(&self, window: [u32; 4]) -> Samples {
let [x0, y0, x1, y1] = window;
Samples {
space: self.space,
kind: self.kind,
width: x1 - x0,
height: y1 - y0,
color: ai_ops::window_samples(&self.color, self.width, usize::from(self.kind), window),
alpha: self.alpha.as_ref().map(|a| ai_ops::window_samples(a, self.width, 1, window)),
}
}
/// `XI`'s fourth flag as the files users sent have it: 0 gray, 1 RGB, 2 CMYK, 16 more with an
/// alpha channel.
fn reserved(&self) -> u8 {
@@ -96,10 +125,13 @@ impl Writer<'_> {
/// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on
/// `%_` lines (other than a styled object's copy on the layers, [`Self::styled_object`]) and in
/// a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is
/// cut to a clipping path, the image is in a clipping group of that path.
/// cut to a clipping path, the image is in a clipping group of that path; a raster of pixels
/// written whole already is then the part of them that the path shows ([`ai_ops::image_window`]).
pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) {
let doc = self.doc;
let Some((samples, deep)) = doc.image_blob(im).and_then(Samples::of) else {
let blob = doc.image_blob(im);
let read = blob.and_then(|b| self.samples(b));
let Some((samples, deep)) = read.as_deref() else {
let bounds = PathData::from_bezpath(&(im.xf * Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1)));
self.warn(IMAGES);
return self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(Color::gray(0.5))), xy);
@@ -113,7 +145,7 @@ impl Writer<'_> {
if !self.take_objects(1 + clipping) {
return;
}
if deep {
if *deep {
self.warn(IMAGE_DEPTH);
}
let cut = self.clip.take();
@@ -129,13 +161,41 @@ impl Writer<'_> {
Some(link) if (self.hidden == 0 || self.styled_copy) && !self.in_symbol => {
// Cut to a region, the placed file is in a clipping group.
self.placed = cut.is_none();
self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf);
self.placed_file(link, samples, (im.width.max(1), im.height.max(1)), im.xf);
}
other => {
if other.is_some() {
self.warn(LINKS_EMBEDDED);
}
self.raster(&samples, im.xf * fit);
// An image written whole already: the part its region shows, placed where it is in
// the image (the pieces of a warped image each show a small part of the same one).
// An object's editable original (on `%_` lines) and an image in an opacity mask's art
// are written whole.
let data = blob.map(|b| &b.bytes);
let written = self.hidden == 0 && data.is_some_and(|d| self.whole_images.contains_key(&(Arc::as_ptr(d) as usize)));
let window = written
.then(|| {
let clips: Vec<_> = self.shown.iter().cloned().chain(cut.iter().map(PathData::to_bezpath)).collect();
ai_ops::image_window(&clips, im.xf * fit, (samples.width, samples.height))
})
.flatten();
match (window, &read) {
(Some(window), Some(read)) => {
let at = self.body.len();
self.raster(&samples.window(window), im.xf * fit * Affine::translate((f64::from(window[0]), f64::from(window[1]))));
self.windowed =
Some(Windowed { at: at..self.body.len(), payload: self.payloads.len() - 1, samples: read.clone(), xf: im.xf * fit });
}
_ => {
if let Some(d) = data {
// `raster` adds the samples as the next payload. A copy written whole on
// `%_` lines after an earlier whole copy keeps the earlier one's entry.
let next = self.payloads.len();
self.whole_images.entry(Arc::as_ptr(d) as usize).or_insert_with(|| (d.clone(), next));
}
self.raster(samples, im.xf * fit);
}
}
}
}
if let Some(region) = &cut {
@@ -232,6 +292,32 @@ impl Writer<'_> {
);
}
/// The pixels of `blob` ([`Samples::of`]), read once for images that follow one another with
/// the same data.
fn samples(&mut self, blob: &ImageBlob) -> Option<Read> {
if let Some((last, read)) = &self.last_samples
&& Arc::ptr_eq(&last.bytes, &blob.bytes)
&& last.mime == blob.mime
{
return read.clone();
}
let read = Samples::of(blob).map(Arc::new);
self.last_samples = Some((blob.clone(), read.clone()));
read
}
/// The copy `w`, written as a window, written whole in its place, with fresh samples; its
/// window's samples are emptied.
pub(super) fn write_whole(&mut self, w: Windowed) {
let tail = self.body.split_off(w.at.end);
self.body.truncate(w.at.start);
if let Some(p) = self.payloads.get_mut(w.payload) {
*p = Vec::new();
}
self.raster(&w.samples.0, w.xf);
self.body.push_str(&tail);
}
/// Keep `bytes` to go in the program where the mark this returns is.
fn payload(&mut self, bytes: Vec<u8>) -> String {
self.payloads.push(bytes);
+2 -2
View File
@@ -481,7 +481,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas),
self.state.take(),
self.gs.replace(Xy::OPAQUE),
self.clip.take(),
(self.clip.take(), std::mem::take(&mut self.shown)),
self.block_end,
);
// `Bg` places a gradient in a pattern's tile as on the canvas, mirrored top to bottom: its
@@ -505,7 +505,7 @@ impl Writer<'_> {
self.hidden -= 1;
self.nest -= 1;
let art;
(art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, self.clip, self.block_end) = saved;
(art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, (self.clip, self.shown), self.block_end) = saved;
let art = std::mem::replace(&mut self.body, art);
self.defining.pop();
if self.left_out_since(before) {
@@ -750,6 +750,9 @@ impl Writer<'_> {
let xy = Xy::of(orig);
let was = self.in_look;
self.in_look = true;
// The reader draws the look apart from the clipping groups around the object to compare it
// with the object's effects, and keeps the object itself: neither is cut to those groups.
let shown_by = std::mem::take(&mut self.shown);
self.line("u");
let mut look = n.clone();
(look.name, look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape) =
@@ -800,6 +803,7 @@ impl Writer<'_> {
}
self.dictionary(orig);
self.hidden -= 1;
self.shown = shown_by;
let part = std::mem::replace(&mut self.body, saved);
hide(&part, &mut self.body);
self.block_end = usize::MAX;
+2 -2
View File
@@ -245,7 +245,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas, self.instance_space),
self.state.replace((false, false)),
self.gs.replace(Xy::OPAQUE),
self.clip.take(),
(self.clip.take(), std::mem::take(&mut self.shown)),
(self.block_start, self.block_end),
(std::mem::replace(&mut self.in_symbol, true), std::mem::take(&mut self.hidden)),
self.root_entries.take(),
@@ -302,7 +302,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas, self.instance_space),
self.state,
self.gs,
self.clip,
(self.clip, self.shown),
(self.block_start, self.block_end),
(self.in_symbol, self.hidden),
self.root_entries,
+50 -1
View File
@@ -7,7 +7,7 @@ use std::fmt::Write;
use vectorcraft_color::{Color, Gradient};
use vectorcraft_doc::{LAYER_COLORS, LayerColor, LineCap, LineJoin, StrokeLayer};
use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath};
use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, Shape, SubPath};
use crate::ps;
@@ -131,6 +131,55 @@ pub(crate) fn average(g: &Gradient) -> Color {
Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n))
}
/// The pixels an image's part keeps past the edge of the region that shows it, on each side: as
/// many as smoothing reads from beside a pixel, and one more.
const WINDOW_MARGIN: f64 = 2.0;
/// The part of an image of `w` × `h` pixels, which `to_doc` places in the document, that regions
/// `clips` (each in the document, the image cut to all of them) show, with [`WINDOW_MARGIN`] pixels
/// more on each side: `[x0, y0, x1, y1]`, in pixels from the top left. One pixel of the image, the
/// nearest to where the regions are, when they show none of it. `None` when that part has half the
/// image's pixels or more, or when `to_doc` can't be undone; the whole image is written then.
pub(crate) fn image_window(clips: &[kurbo::BezPath], to_doc: Affine, (w, h): (u32, u32)) -> Option<[u32; 4]> {
if clips.is_empty() || w == 0 || h == 0 || !to_doc.is_finite() || to_doc.determinant().abs() < 1e-12 {
return None;
}
let to_pixels = to_doc.inverse();
let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, f64::from(w), f64::from(h));
for clip in clips {
let b = (to_pixels * clip.clone()).bounding_box();
if !b.is_finite() {
return None;
}
x0 = f64::max(x0, b.x0.floor() - WINDOW_MARGIN);
y0 = f64::max(y0, b.y0.floor() - WINDOW_MARGIN);
x1 = f64::min(x1, b.x1.ceil() + WINDOW_MARGIN);
y1 = f64::min(y1, b.y1.ceil() + WINDOW_MARGIN);
}
if x0 >= x1 || y0 >= y1 {
let (x, y) = ((x0 as u32).min(w - 1), (y0 as u32).min(h - 1));
return Some([x, y, x + 1, y + 1]);
}
if 2.0 * (x1 - x0) * (y1 - y0) >= f64::from(w) * f64::from(h) {
return None;
}
Some([x0 as u32, y0 as u32, x1 as u32, y1 as u32])
}
/// The rows `y0..y1` and columns `x0..x1` (`window`) of `samples`, `w` pixels a row of `channels`
/// bytes each, rows from the top.
pub(crate) fn window_samples(samples: &[u8], w: u32, channels: usize, [x0, y0, x1, y1]: [u32; 4]) -> Vec<u8> {
let row = w as usize * channels;
let (from, to) = (x0 as usize * channels, x1 as usize * channels);
let mut out = Vec::with_capacity((y1 - y0) as usize * (to - from));
for y in y0 as usize..y1 as usize {
if let Some(part) = samples.get(y * row + from..y * row + to) {
out.extend_from_slice(part);
}
}
out
}
/// The stroke's width, cap, join, miter limit and dashes: `w J j M d`.
pub(crate) fn line_style(s: &mut String, st: &StrokeLayer) {
let cap = match st.cap {
+2 -1
View File
@@ -18,7 +18,8 @@
//! 1 pt draws it at its own size), the brush by name (`/GObjRef : (name) (plug-in)
//! /PluginObject`), and the brush's settings for this path as entries: `Diameter`, `Roundness`,
//! `Angle`, their `…Variation` either side and `…DependsOn` codes, and `AngleRelativeTo` (0 the
//! page).
//! page). Samples of the pen may follow a path's own lines on `%_` lines (`(hex) Xt n XD`). They
//! aren't read, so such a path keeps its drawn look when a setting varies with the pen.
//! - A path with an Art brush is a stroke in its art style too: the filter
//! `(Adobe ArtOnPath Brush Tool)`, its stroke's paint and weight (the weight scales the art across
//! the path), the brush by name, and its settings for this path as entries: `Scaling`,
+5
View File
@@ -599,6 +599,10 @@ struct Reader<'a> {
compound_groups: HashSet<NodeId>,
/// Groups of a text object and its look (see [`Self::style_marker`]), which aren't a look.
text_looks: HashSet<NodeId>,
/// Objects followed by samples of the pen (`(…) Xt n XD`), which aren't read: such an object
/// whose style has a Calligraphic brush that varies with the pen keeps its drawn look (see
/// [`Self::style_marker`]).
pen_samples: HashSet<NodeId>,
/// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line.
mesh: Option<(super::mesh::MeshReader, bool)>,
/// The art styles (`%AI9_BeginArtStyles`), by name.
@@ -791,6 +795,7 @@ impl<'a> Reader<'a> {
ruler_guides: HashMap::new(),
compound_groups: HashSet::new(),
text_looks: HashSet::new(),
pen_samples: HashSet::new(),
group_ended: false,
mesh_ended: false,
layer_state: None,
+32 -5
View File
@@ -89,6 +89,18 @@ impl<'a> Reader<'a> {
}
}
/// `(…) Xt n XD`: samples of the pen along the object before it. They aren't read, so the object
/// keeps its drawn look when its style has a Calligraphic brush with a setting that varies with
/// the pen ([`Self::style_marker`]).
fn pen_samples_op(&mut self) {
if !self.after_object || self.pen_samples.len() >= MAX_NODES {
return;
}
if let Some(n) = self.frames.last().and_then(|f| f.children.last()) {
self.pen_samples.insert(n.id);
}
}
/// `/identifier (string) XT` (version 7 specification §7): a tag on the object before it. The
/// `AdobeURL` tag sets the object's URL, and every other tag is added to the object's data in
/// order, up to [`MAX_TAGS`]. A tag with no object before it, with an empty identifier, or with
@@ -418,6 +430,7 @@ impl<'a> Reader<'a> {
self.end_envelope();
}
}
"XD" => self.pen_samples_op(),
_ if IGNORED.contains(&w) => {}
// Before the art, the setup runs procedures of the printing format.
_ if self.frames.is_empty() => {}
@@ -559,9 +572,9 @@ impl<'a> Reader<'a> {
return;
}
self.image_tail = false;
// An object's tags (`XT`, and a graph's `Go`) follow it one after another, and an image's
// follow the `XH` that ends its data.
if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go") {
// An object's tags (`XT`, and a graph's `Go`) and samples of the pen (`Xt`, `XD`) follow it
// one after another, and an image's tags follow the `XH` that ends its data.
if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go" | "Xt" | "XD") {
return;
}
self.after_object = false;
@@ -688,6 +701,8 @@ impl<'a> Reader<'a> {
/// drawn look before it (all on `%_` lines inside another such object). Other markers end what
/// follows an object.
fn style_marker(&mut self, vals: &[V], hidden: bool) {
use crate::import::ai::brush::PathBrush;
use crate::import::ai::style::{ArtStyle, StyleItem};
let code = Self::nums(vals).last().copied().unwrap_or(0.0);
let style = vals.iter().rev().find_map(|v| self.names.text(v)).unwrap_or_default();
let after = std::mem::take(&mut self.after_object);
@@ -754,8 +769,20 @@ impl<'a> Reader<'a> {
} else {
look
};
// A style read here: the object, painted as it says.
let appearance = self.styles.get(&style).and_then(|st| {
// A style read here: the object, painted as it says. An object with samples of the pen
// keeps its drawn look when its style has a Calligraphic brush with a setting that varies
// with the pen: the samples aren't read, and the brush draws such a stroke at the pen's
// middle values.
let sampled = self.pen_samples.contains(&object.id);
let follows_pen = |st: &ArtStyle| {
st.items.iter().any(|i| match i {
StyleItem::Stroke { brush: Some((_, PathBrush::Calligraphic(c))), .. } => {
c.modes().iter().zip(c.variation).any(|(m, v)| m.follows_pen() && v != 0.0)
}
_ => false,
})
};
let appearance = self.styles.get(&style).filter(|st| !(sampled && follows_pen(st))).and_then(|st| {
styled(
object,
look,
+1 -2
View File
@@ -386,9 +386,8 @@ pub(super) const IGNORED: &[&str] = &[
// Path resolution (`Ar`, §12): how finely curves print.
"Ar",
"Xs",
// The samples of the pen that `XD` notes on the object before them (`Reader::pen_samples`).
"Xt",
// Five indexed curves of samples along a brushed path (`(…) Xt n XD`), on its hidden source path.
"XD",
"Xi",
"XI",
"LB2",
+28 -3
View File
@@ -1445,13 +1445,38 @@ fn offset_stroke(f: &Filter) -> Result<Option<StyleItem>, ()> {
stroke_item(f, if inside { StrokeAlign::Inside } else { StrokeAlign::Outside })
}
/// The entries a Calligraphic brush's filter may hold, with the settings [`calligraphic_stroke`]
/// reads. Any other entry keeps the drawn look.
const CALLIGRAPHIC_ENTRIES: [&str; 14] = [
"StrokeStyle",
"DisplayString",
"Brush",
"Diameter",
"DiameterVariation",
"DiameterDependsOn",
"Roundness",
"RoundnessVariation",
"RoundnessDependsOn",
"Angle",
"AngleVariation",
"AngleDependsOn",
"AngleRelativeTo",
"Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files
];
/// A Calligraphic brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a
/// colour, names no Calligraphic brush, or its settings aren't all there as numbers. An angle
/// relative to the path isn't read.
/// color, names no Calligraphic brush, holds an entry or a width that isn't read, or its settings
/// aren't all there as numbers. An angle relative to the path isn't read.
fn calligraphic_stroke(f: &Filter) -> Option<StyleItem> {
let real = |key: &str| entry(f, key, Kind::Real).ok().flatten();
let code = |key: &str| entry(f, key, Kind::Int).ok().flatten();
if !f.paints.0 || f.gradient || f.pattern.is_some() || f.repeated {
if !f.paints.0
|| f.gradient
|| f.pattern.is_some()
|| f.repeated
|| !f.arrays.is_empty()
|| !f.kinds.keys().all(|k| CALLIGRAPHIC_ENTRIES.contains(&k.as_str()))
{
return None;
}
if entry(f, "AngleRelativeTo", Kind::Int).ok()?.unwrap_or(0.0) != 0.0 {
+67 -1
View File
@@ -10304,20 +10304,86 @@ fn a_path_with_a_calligraphic_brush_comes_in_brushed() {
let pieces = vectorcraft_brush::stroke_pieces(&r.doc, &lib[0], &a[0].path_data().unwrap().to_bezpath(), st);
let height = pieces.iter().filter_map(|n| n.geometric_bounds()).map(|b| b.height()).fold(0.0, f64::max);
assert!(height > 40.0 && height < 46.0, "the line's rise and about half the nib's 3 pt: {height}");
// A filter with the version entry an effect's filter may carry still comes in brushed.
let versioned = style.replace("(Brush) ,\n", "(Brush) ,\n15 /Int (Adobe Effect Expand Before Version) ,\n");
let a = art(&read_ok(&format!("{def}{versioned}{}", layer(&brushed_path()))).doc);
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{a:#?}");
}
/// Samples of the pen along a path (`(…) Xt n XD`), as they follow its own lines.
const PEN_SAMPLES: &str = "%_(00FE64FE) Xt 0 XD\n%_(00006400) Xt 1 XD\n%_(00986498) Xt 2 XD\n%_(009E649E) Xt 3 XD\n%_(007F647F) Xt 4 XD\n";
/// A path with a Calligraphic brush that varies with the pen, followed by samples of the pen, comes
/// in as its drawn look alone, with the note that says so: the samples aren't read, and the brush
/// would draw it at the pen's middle values. Its own path doesn't come in beside the look. A brush
/// that doesn't follow the pen draws such a path the same without the samples, so it comes in
/// brushed.
#[test]
fn a_brushed_path_with_samples_of_the_pen_keeps_its_drawn_look() {
// GUARDS: the path came in beside its look, unpainted, and the note was missing.
let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D);
let style = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [2, 5, 6], 0);
let path = brushed_path().replace("%_n\n", &format!("%_n\n{PEN_SAMPLES}"));
let r = read_ok(&format!("{def}{style}{}", layer(&path)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the drawn look: {a:#?}");
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
// The samples belong to the path before them: a path after them is read as it was.
let after = format!("{path}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0));
let a = art(&read_ok(&format!("{def}{style}{}", layer(&after))).doc);
assert_eq!(a.len(), 2, "{a:#?}");
assert!(matches!(a[1].kind, NodeKind::Path { .. }) && a[1].appearance.fill().is_some(), "{a:#?}");
// Samples with no object before them in the layer mark nothing: the path after them is read.
let first = format!("{PEN_SAMPLES}{}", brushed_path());
let a = art(&read_ok(&format!("{def}{style}{}", layer(&first))).doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path, brushed: {a:#?}");
// A brush whose modes are Fixed and Random, one whose pen modes vary by 0, and an Art brush
// don't follow the pen: the path comes in brushed.
let fixed_def = plugin_object(CALLIGRAPHIC, "Six D", &SIX_D.replace(" 2 5 6 0", " 0 1 0 0"));
let fixed = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [0, 1, 0], 0);
let even_def = plugin_object(CALLIGRAPHIC, "Six D", "1 6 6 40 40 30 30 2 5 6 0");
let even = brushed_style("Six D", 0.5, [(6.0, 0.0), (40.0, 0.0), (30.0, 0.0)], [2, 5, 6], 0);
for (why, def, style) in [("Fixed and Random", fixed_def, fixed), ("no variation", even_def, even)] {
let r = read_ok(&format!("{def}{style}{}", layer(&path)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{why}: {a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{why}: the path, brushed: {a:#?}");
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings);
}
let ribbon = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON));
let a = art(&read_ok(&format!("{ribbon}{}{}", art_style("Ribbon", 2.0, &STRETCHED, ""), layer(&path))).doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"), "{a:#?}");
// A style with no brush doesn't draw the samples: the path takes it as it would without them.
let square = styled_square("Blue").replace("f\n1 (Blue) XW", &format!("f\n{PEN_SAMPLES}1 (Blue) XW"));
let r = read_ok(&format!("{}{}", styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]), layer(&square)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path with its style: {a:#?}");
assert_eq!(a[0].extras().graphic_style, Some(1), "linked to its style");
}
/// A brushed path keeps its drawn look when its stroke can't be read as the brush: the object's
/// own settings differ from the brush's (Options of Selected Object), its angle follows the path,
/// or the file defines no brush of its name.
/// its filter holds an entry or a variable width that isn't read, or the file defines no brush of
/// its name.
#[test]
fn a_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D);
let mut own = SIX_D_SETTINGS;
own[0] = (9.0, 2.0);
let plain = brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0);
let with = |more: &str| plain.replace("(Brush) ,\n", &format!("(Brush) ,\n{more}"));
let widths = "/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
/Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n";
let cases = [
("its own settings", def.clone(), brushed_style("Six D", 1.0, own, [2, 5, 6], 0)),
("its own modes", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 4], 0)),
("an angle on the path", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 1)),
("an entry that isn't read", def.clone(), with("1 /Real (Unread) ,\n")),
("a variable width", def.clone(), with(widths)),
("no such brush", def, brushed_style("Other", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0)),
];
for (why, def, style) in cases {
+176 -2
View File
@@ -26,8 +26,8 @@ use std::collections::{BTreeMap, HashMap};
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{
CharStyle, HyphenationOptions, JustificationOptions, Justify, LineCap, LineJoin, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign,
PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun,
Burasagari, CharStyle, HyphenationOptions, JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LineCap, LineJoin, Node, NodeId, NodeKind,
ParaDirection, ParaStyle, PathAlign, PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun,
};
use vectorcraft_geom::{Affine, PathData, Point, Rect};
@@ -405,6 +405,42 @@ struct Para {
tabs: Vec<TabStop>,
/// The paragraph has more than [`MAX_TABS`] stops: those past them were left out.
tabs_truncated: bool,
/// Key 27: the Kinsoku set, `/nil` for None, else the index of one of the document's sets
/// ([`Texts::kinsoku_sets`]). `None`: the default.
kinsoku: Option<Kinsoku>,
/// Key 27 names a set of the document that isn't Hard or Soft: the paragraph takes the set it
/// would have without it.
kinsoku_custom: bool,
/// Key 25: Kinsoku Shori Type, 0 Push In First, 1 Push Out First, 2 Push Out Only. `None`: the
/// default.
kinsoku_type: Option<KinsokuShoriType>,
/// Key 24: Burasagari, 0 None, 1 Regular, 2 Force. `None`: the default.
burasagari: Option<Burasagari>,
/// Key 23: Bunri-Kinshi.
bunri_kinshi: bool,
}
/// The name of the document's predefined Hard Kinsoku set (key 0 of its entry in the document's
/// Kinsoku sets).
const KINSOKU_HARD: &str = "PhotoshopKinsokuHard";
/// The name of the document's predefined Soft Kinsoku set.
const KINSOKU_SOFT: &str = "PhotoshopKinsokuSoft";
/// The warning when a paragraph's Kinsoku set is one VectorCraft doesn't have.
pub(crate) const KINSOKU_SETS_LEFT_OUT: &str = "custom Kinsoku sets were left out: their paragraphs take the set their paragraph style has";
/// The Kinsoku sets of text document `doc` (the list at key 0 of key 4 of its resources), in
/// order, each by its name (key 0 of the set): Hard or Soft, `None` for any other set. Those past
/// [`MAX_SHEETS`] are left out.
fn kinsoku_sets(doc: &Val) -> Vec<Option<Kinsoku>> {
let list = doc.get("0").and_then(|d| d.get("4")).and_then(|s| s.get("0")).map(Val::list).unwrap_or_default();
list.iter()
.take(MAX_SHEETS)
.map(|s| match s.get("0").and_then(|r| r.get("0")).and_then(Val::str) {
Some(KINSOKU_HARD) => Some(Kinsoku::Hard),
Some(KINSOKU_SOFT) => Some(Kinsoku::Soft),
_ => None,
})
.collect()
}
/// Most tab stops read for one paragraph.
@@ -542,6 +578,9 @@ pub(super) struct Texts {
/// The patterns that paint characters, by the name of the art style that gives each
/// ([`Self::with_patterns`]).
patterns: HashMap<String, Paint>,
/// The document's Kinsoku sets (key 4 of its resources), in order, each by its name (key 0):
/// Hard or Soft, `None` for any other set.
kinsoku_sets: Vec<Option<Kinsoku>>,
}
impl Texts {
@@ -611,6 +650,7 @@ impl Texts {
para_sheets: sheets("6"),
frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(),
patterns: HashMap::new(),
kinsoku_sets: kinsoku_sets(&doc),
})
}
@@ -749,7 +789,37 @@ impl Texts {
chain.extend(self.para_sheets.first().and_then(|s| s.get("5")));
let pick = |k: &str| chain.iter().find_map(|s| s.get(k)).or_else(|| self.para_defaults.get(k));
let num = |k: &str| pick(k).and_then(Val::num).filter(|v| v.abs() < 1e5).unwrap_or(0.0);
// The East Asian settings: the first value along the chain that can be read, so one that
// can't be read takes the paragraph style's.
let levels = || chain.iter().copied().chain(std::iter::once(&self.para_defaults));
let choice = |k: &str| {
levels().find_map(|s| match s.get(k) {
Some(Val::Num(v)) if *v == 0.0 || *v == 1.0 || *v == 2.0 => Some(*v as u8),
_ => None,
})
};
let set = |v: &Val| match v {
Val::Name(n) if n == "nil" => Some(Some(Kinsoku::None)),
Val::Num(i) if *i >= 0.0 && i.fract() == 0.0 => self.kinsoku_sets.get(*i as usize).copied(),
_ => None,
};
Para {
kinsoku: levels().find_map(|s| s.get("27").and_then(set).flatten()),
kinsoku_custom: levels().find_map(|s| s.get("27").and_then(set)).is_some_and(|k| k.is_none()),
kinsoku_type: choice("25").map(|v| match v {
0 => KinsokuShoriType::PushInFirst,
1 => KinsokuShoriType::PushOutFirst,
_ => KinsokuShoriType::PushOutOnly,
}),
burasagari: choice("24").map(|v| match v {
0 => Burasagari::None,
1 => Burasagari::Standard,
_ => Burasagari::Forced,
}),
bunri_kinshi: levels().find_map(|s| match s.get("23") {
Some(Val::Bool(b)) => Some(*b),
_ => None,
}) == Some(true),
justify: pick("0").and_then(Val::num).map_or(0, |v| v.clamp(0.0, 6.0) as u8),
rtl: pick("33").and_then(Val::num) == Some(1.0),
first_indent: num("1"),
@@ -1264,6 +1334,11 @@ impl Story {
self.tabs_left_out.get()
}
/// Does a paragraph have a Kinsoku set VectorCraft doesn't have ([`Para::kinsoku_custom`])?
pub(super) fn kinsoku_sets_left_out(&self) -> bool {
self.paras.iter().any(|(_, p)| p.kinsoku_custom)
}
/// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and
/// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its
/// page draws it: its first baseline is fixed where the file has it. The font's ascent below the
@@ -1425,6 +1500,10 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle {
auto_leading: p.auto_leading.map_or(JustificationOptions::default().auto_leading, |a| (a * 1e6).round() / 1e4),
..JustificationOptions::default()
},
kinsoku: p.kinsoku.unwrap_or_default(),
kinsoku_type: p.kinsoku_type.unwrap_or_default(),
burasagari: p.burasagari.unwrap_or_default(),
bunri_kinshi: p.bunri_kinshi,
..ParaStyle::default()
}
}
@@ -1496,6 +1575,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let justify = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justify;
// Right to left from the document's defaults, or the paragraph's own.
@@ -1520,6 +1600,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let auto = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justification.auto_leading;
assert_eq!(auto(b""), JustificationOptions::default().auto_leading);
@@ -1543,6 +1624,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let options = |own: &[u8], scale: f64| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale).hyphenation;
let want = HyphenationOptions { words_longer_than: 6, after_first: 2, before_last: 2, hyphen_limit: 0, zone: 36.0, capitalized: true };
@@ -1561,12 +1643,93 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let own = |own: &[u8]| para_style(&bare.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).hyphenation;
assert_eq!(own(b""), d);
assert_eq!(own(b"/10 0 /11 99 /12 2.5 /13 -1 /14 -3 /15 1"), d);
}
/// The document's Kinsoku sets are known by their names, in order: the predefined Hard and
/// Soft sets, in either string form, and any other set as one VectorCraft doesn't have.
#[test]
fn the_documents_kinsoku_sets_are_known_by_name() {
let utf16: Vec<u8> = b"(\xfe\xff".iter().copied().chain(KINSOKU_SOFT.bytes().flat_map(|c| [0, c])).chain(*b")").collect();
let doc = read(
&[
b"/0 << /4 << /0 [ << /0 << /0 (PhotoshopKinsokuHard) /5 << /4 1 >> >> >> << /0 << /0 ".as_slice(),
&utf16,
b" >> >> << /0 << /0 (My Set) >> >> << /0 << >> >> ] >> >>",
]
.concat(),
)
.unwrap();
assert_eq!(kinsoku_sets(&doc), [Some(Kinsoku::Hard), Some(Kinsoku::Soft), None, None]);
assert_eq!(kinsoku_sets(&read(b"/0 << >>").unwrap()), []);
}
/// Keys 23 to 27 are the paragraph's East Asian settings: Bunri-Kinshi (23), Burasagari (24:
/// 0 None, 1 Regular, 2 Force), Kinsoku Shori Type (25: 0 Push In First, 1 Push Out First, 2
/// Push Out Only) and the Kinsoku set (27: `/nil` for None, else the index of one of the
/// document's sets). A value that can't be read, and a set VectorCraft doesn't have, take the
/// paragraph style's, or else the document's.
#[test]
fn a_paragraph_keeps_its_east_asian_settings() {
let texts = Texts {
stories: vec![],
fonts: vec![],
defaults: Val::Dict(BTreeMap::new()),
para_defaults: read(b"/27 0 /25 2 /24 0 /23 false").unwrap(),
char_sheets: vec![],
// [Normal Paragraph Style] has the Soft set; the second style has Force burasagari.
para_sheets: vec![read(b"/5 << /27 1 >>").unwrap(), read(b"/5 << /24 2 /23 true >>").unwrap()],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![Some(Kinsoku::Hard), Some(Kinsoku::Soft), None],
};
let para = |run: &[u8]| texts.para(Some(&read(run).unwrap()));
let settings = |run: &[u8]| {
let p = para_style(&para(run), 1.0);
(p.kinsoku, p.kinsoku_type, p.burasagari, p.bunri_kinshi)
};
let normal = (Kinsoku::Soft, KinsokuShoriType::PushOutOnly, Burasagari::None, false);
assert_eq!(settings(b"/5 << >>"), normal);
assert_eq!(settings(b"/5 << /27 /nil /25 0 /24 1 /23 true >>"), (Kinsoku::None, KinsokuShoriType::PushInFirst, Burasagari::Standard, true));
assert_eq!(settings(b"/5 << /27 0 /25 1 /24 2 >>"), (Kinsoku::Hard, KinsokuShoriType::PushOutFirst, Burasagari::Forced, false));
// The paragraph style it is based on (key 6), then [Normal Paragraph Style].
assert_eq!(settings(b"/5 << >> /6 1"), (Kinsoku::Soft, KinsokuShoriType::PushOutOnly, Burasagari::Forced, true));
assert_eq!(settings(b"/5 << /23 false >> /6 1"), (Kinsoku::Soft, KinsokuShoriType::PushOutOnly, Burasagari::Forced, false));
// A set VectorCraft doesn't have takes the paragraph style's, and the paragraph says so.
assert_eq!(settings(b"/5 << /27 2 >>"), normal);
assert!(para(b"/5 << /27 2 >>").kinsoku_custom);
assert!(!para(b"/5 << /27 0 >>").kinsoku_custom && !para(b"/5 << >>").kinsoku_custom);
// The story warns of it (the importer's warning).
assert!(story("あ\r", &[0.0], &para(b"/5 << /27 2 >>")).kinsoku_sets_left_out());
assert!(!story("あ\r", &[0.0], &para(b"/5 << /27 1 >>")).kinsoku_sets_left_out());
for unreadable in [
b"/5 << /27 3 >>".as_slice(),
b"/5 << /27 -1 >>",
b"/5 << /27 0.5 >>",
b"/5 << /27 (PhotoshopKinsokuHard) >>",
b"/5 << /27 /Hard >>",
b"/5 << /25 3 >>",
b"/5 << /25 1.5 >>",
b"/5 << /25 -1 >>",
b"/5 << /24 /nil >>",
b"/5 << /24 true >>",
b"/5 << /23 1 >>",
b"/5 << /23 /nil >>",
] {
assert_eq!(settings(unreadable), normal, "{}", String::from_utf8_lossy(unreadable));
assert!(!para(unreadable).kinsoku_custom, "{}", String::from_utf8_lossy(unreadable));
}
// Without any of the keys, VectorCraft's defaults.
let bare = Texts { para_defaults: Val::Dict(BTreeMap::new()), para_sheets: vec![], kinsoku_sets: vec![], ..texts };
let p = para_style(&bare.para(Some(&read(b"/5 << >>").unwrap())), 1.0);
let d = ParaStyle::default();
assert_eq!((p.kinsoku, p.kinsoku_type, p.burasagari, p.bunri_kinshi), (d.kinsoku, d.kinsoku_type, d.burasagari, d.bunri_kinshi));
}
/// Key 30 holds the paragraph's tab stops: their positions (key 0), sorted, and alignments (key
/// 1: 0 left, 1 center, 2 right, 3 decimal), scaled as sizes are. A stop that can't be read is
/// left out. A user's price list keeps a right stop at 269.29 and a left one at 272.13.
@@ -1581,6 +1744,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let tabs = |own: &[u8], scale: f64| {
para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale)
@@ -1648,6 +1812,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
}
.para(None);
let mut s = story(" 80 mm ", &[0.0], &para);
@@ -1696,6 +1861,7 @@ mod tests {
para_sheets: vec![],
frames: cases.iter().map(|c| frame(c)).collect(),
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let vertical: Vec<bool> = (0..cases.len() + 1).map(|i| texts.frame_vertical(i)).collect();
assert_eq!(vertical, [true, false, false, false, false, false, false, false, false, false, false]);
@@ -1714,6 +1880,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let tcy = |run: &[u8]| texts.style(Some(&read(run).unwrap())).tate_chu_yoko;
assert!(tcy(b"/6 << /35 3 >>"), "the run's own");
@@ -1738,6 +1905,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let roman = |defaults: &[u8], run: &[u8]| texts(defaults).style(Some(&read(run).unwrap())).standard_vertical_roman;
assert!(roman(b"/35 1", b"/6 << >>"), "the document's defaults");
@@ -1769,6 +1937,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let stroke = |run: &[u8]| {
let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0, Affine::IDENTITY);
@@ -1811,6 +1980,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
}
.with_patterns(patterns)
}
@@ -1894,6 +2064,7 @@ mod tests {
para_sheets: vec![],
frames: vec![read(b"/0 << /0 [ 0 0 ] /2 << /0 0 >> >>").unwrap()],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let text = |kerns: &[u8]| -> TextObject {
let node = texts(kerns).story(0).unwrap().node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY).unwrap();
@@ -1961,6 +2132,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let direction = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).direction;
assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults");
@@ -2009,6 +2181,7 @@ mod tests {
para_sheets: vec![],
frames: vec![read(doc.as_bytes()).unwrap()],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
texts.frame(0)
}
@@ -2667,6 +2840,7 @@ mod tests {
para_sheets: vec![],
frames: vec![read(doc.as_bytes()).unwrap()],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
texts.frame(0).unwrap()
}
+5 -1
View File
@@ -59,7 +59,7 @@ pub(super) fn make_slots(
to_doc: Affine,
warn: &mut Vec<String>,
) -> Vec<Option<Made>> {
let (mut taller, mut deeper, mut tabs_left_out) = (0, 0, false);
let (mut taller, mut deeper, mut tabs_left_out, mut kinsoku_left_out) = (0, 0, false, false);
let made = infos
.iter()
.enumerate()
@@ -68,6 +68,7 @@ pub(super) fn make_slots(
let story = stories.get(k)?.as_ref()?;
let mut node = story.node(NodeId(0), info.frame, t, to_doc)?;
tabs_left_out |= story.tabs_left_out();
kinsoku_left_out |= story.kinsoku_sets_left_out();
let mut region = story.region(t, to_doc);
// The first line where the file has it, in the text document's frame (that of a
// copied object is its original's, as its lines are).
@@ -102,6 +103,9 @@ pub(super) fn make_slots(
if tabs_left_out {
warn.push(crate::import::ate::TABS_LEFT_OUT.into());
}
if kinsoku_left_out {
warn.push(crate::import::ate::KINSOKU_SETS_LEFT_OUT.into());
}
made
}
+41
View File
@@ -658,6 +658,47 @@ fn images_are_written_in_the_form_the_specification_shows() {
assert!(rgb.iter().zip([247.0, 148.0, 29.0]).all(|(c, want)| (c * 255.0 - want).abs() < 0.1), "{rgb:?}");
}
/// The pieces of an image cut into a raster mesh warp (as Object › Expand leaves a Warp on an
/// image) are written as the image whole once, then each as the part of it that its triangle shows,
/// and the file draws as the document does ([`write`] runs it).
#[test]
fn the_pieces_of_a_warped_image_are_written_as_the_parts_they_show() {
// GUARDS: each of a warped image's 200 pieces was written with all of the image's samples.
let mut doc = Document::new(200.0, 160.0);
let mut png = vec![];
image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let blob = ImageBlob::new("image/png", png);
let key = blob.content_key();
doc.images.insert(key.clone(), blob);
let im = ImageObject {
key,
width: 64,
height: 64,
xf: Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]),
link: None,
placement: Default::default(),
pixels: None,
};
let photo = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im.clone())));
let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin());
let warped = vectorcraft_doc::live::mesh_warp_image(&photo, &im, &bend, 10, false, false);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, warped).unwrap();
let text = String::from_utf8(write(&doc).bytes).unwrap();
let images = rasters(&text);
assert_eq!(images.len(), 200);
let digits = |lines: &Vec<&str>| {
lines.iter().filter_map(|l| l.strip_prefix('%')).filter(|l| l.bytes().all(|b| b.is_ascii_hexdigit())).map(str::len).sum::<usize>()
};
let whole = 64 * 64 * 3 * 2;
assert_eq!(digits(&images[0]), whole, "the first piece holds the whole image");
// Each piece holds its cell (two triangles share one) and 2 pixels more around it.
let total: usize = images.iter().map(digits).sum();
assert!(total < 10 * whole, "{total} digits of samples for an image of {whole}");
}
/// An image whose data is empty is left out with the note for empty or too large images, and an
/// image whose data can't be decoded is left out with the decode note.
#[test]
+52 -9
View File
@@ -45,6 +45,13 @@ pub(super) struct Writer<'a> {
roman: bool,
/// Inside a Kumi block `1 TY` began (Tate-chu-yoko in horizontal type).
kumi: bool,
/// The outlines of the clipping groups the art being written is in, in the document.
clips: Vec<BezPath>,
/// The image decoded last (`None` when it can't be): the pieces of a warped image, which follow
/// one another, decode it once.
last_image: Option<(ImageBlob, Option<Arc<image::DynamicImage>>)>,
/// The images written whole so far, by the address of their data (which each entry keeps).
whole_images: std::collections::HashMap<usize, Arc<Vec<u8>>>,
}
impl<'a> Writer<'a> {
@@ -68,6 +75,9 @@ impl<'a> Writer<'a> {
fonts: vec![],
roman: false,
kumi: false,
clips: vec![],
last_image: None,
whole_images: std::collections::HashMap::new(),
}
}
@@ -363,12 +373,14 @@ impl<'a> Writer<'a> {
if several {
self.line("*U");
}
self.clips.push(outline);
if let Some(fill) = &paint.fill {
self.node(fill, locked);
}
for c in rest {
self.node(c, locked);
}
self.clips.pop();
self.lock(locked);
self.line("Q");
if let Some(stroke) = &paint.stroke {
@@ -1037,7 +1049,8 @@ impl<'a> Writer<'a> {
/// whose pixels are all black or white is written as an image mask (ImageMask 1, §8.1): its
/// color as the fill, then 1-bit samples, a 0 bit for each black pixel, which takes the color,
/// and a 1 bit for each white pixel, which is clear. Another colorized image is written with
/// its color in its pixels.
/// its color in its pixels. In a clipping group, an image whose pixels were written whole
/// already is written as the part of them the group shows ([`ai_ops::image_window`]).
fn image(&mut self, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine, colorize: Option<&Paint>) {
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let m = self.to_art * xf * flip;
@@ -1048,7 +1061,7 @@ impl<'a> Writer<'a> {
self.warn(LARGE_IMAGE);
return;
}
let Some(img) = blob.and_then(|b| image::load_from_memory(&b.bytes).ok()) else {
let Some((blob, img)) = blob.and_then(|b| Some((b, self.decoded(b)?))) else {
self.warn(UNDECODED);
return;
};
@@ -1059,14 +1072,31 @@ impl<'a> Writer<'a> {
}
// The object places the image's own pixel grid (its width × height) wherever the file's is.
let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h));
let m = self.to_art * xf * fit * flip;
let coverage = match colorize {
Some(paint) => {
let Some((_, _, coverage)) = ImageBlob::coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else {
self.warn(UNDECODED);
return;
};
Some((paint, coverage))
}
None => None,
};
// An image written whole already, cut to a clipping group: the part the group shows, placed
// where it is in the image (the pieces of a warped image each show a small part of the same one).
let key = Arc::as_ptr(&blob.bytes) as usize;
let window = if self.whole_images.contains_key(&key) { ai_ops::image_window(&self.clips, xf * fit, (w, h)) } else { None };
if window.is_none() {
self.whole_images.insert(key, blob.bytes.clone());
}
let window = window.unwrap_or([0, 0, w, h]);
let [x0, y0, x1, y1] = window;
let m = self.to_art * xf * fit * Affine::translate((f64::from(x0), f64::from(y0))) * flip;
let (w, h) = (x1 - x0, y1 - y0);
// Over white.
let over = |c: u8, a: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8;
if let Some(paint) = colorize {
let Some((_, _, coverage)) = blob.and_then(ImageBlob::coverage).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else {
self.warn(UNDECODED);
return;
};
if let Some((paint, coverage)) = coverage {
let coverage = ai_ops::window_samples(&coverage, img.width(), 1, window);
if coverage.iter().all(|&v| v == 0 || v == 255) {
let stride = (w as usize).div_ceil(8);
let mut bits = vec![0xFF; stride.saturating_mul(h as usize)];
@@ -1086,7 +1116,7 @@ impl<'a> Writer<'a> {
self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples);
return;
}
let rgba = img.to_rgba8();
let rgba = img.crop_imm(x0, y0, w, h).to_rgba8();
let mut samples = Vec::with_capacity(rgba.len() / 4 * 3);
let mut see_through = false;
for px in rgba.pixels() {
@@ -1102,6 +1132,19 @@ impl<'a> Writer<'a> {
self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples);
}
/// The pixels of `blob`, decoded once for images that follow one another with the same data.
fn decoded(&mut self, blob: &ImageBlob) -> Option<Arc<image::DynamicImage>> {
if let Some((last, img)) = &self.last_image
&& Arc::ptr_eq(&last.bytes, &blob.bytes)
&& last.mime == blob.mime
{
return img.clone();
}
let img = image::load_from_memory(&blob.bytes).ok().map(Arc::new);
self.last_image = Some((blob.clone(), img.clone()));
img
}
/// An image's lines (§8.4): `%AI5_File:`, `%AI5_BeginRaster`, matrix `m` as `[ a b c d tx ty ]`
/// and the numbers `operands` with `XI`, `samples` in hex on comment lines of [`HEX_LINE`]
/// digits, `%AI5_EndRaster`, then `F`.
+14 -7
View File
@@ -931,18 +931,21 @@ impl<'a> Scene<'a> {
/// process inks the stops use, in CMYK order, and the spot inks, in the order the stops name
/// them. Paper white adds no ink, a gray stop is black ink, and a spot stop's process plates
/// print with its ink. An off-center midpoint is a stop
/// with the inks halfway between its two stops. `None` for a gradient without a spot ink, and,
/// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method
/// or around the hue wheel; it is then written in process colors.
/// with the inks halfway between its two stops. Stop opacity is left out, so the stops that
/// color a stretch ([`vectorcraft_color::Gradient::reached_stops`]) name the inks, a spot ink
/// only above a tint of 0 or with process plates; the others are written with no ink. `None` for a gradient without a
/// spot ink, and, with a warning, for one whose inks can't be kept or that is mixed by the
/// Perceptual method or around the hue wheel; it is then written in process colors.
fn ink_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<(GradientSpace, RawStops)> {
let doc = self.doc;
let spot_of = |s: &vectorcraft_color::GradientStop| -> Option<String> {
s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot)).map(str::to_string)
};
let reached = g.reached_stops();
// The spot inks, collected up to one more than a DeviceN space can hold.
let mut names: Vec<String> = vec![];
for s in &g.stops {
if let Some(n) = spot_of(s)
for (s, &r) in g.stops.iter().zip(&reached) {
if let Some(n) = spot_of(s).filter(|_| r && (s.tint > 0.0 || s.plates.is_some()))
&& !names.contains(&n)
{
names.push(n);
@@ -971,9 +974,10 @@ impl<'a> Scene<'a> {
};
// Each stop's ink coverage: the four process inks, then one tint per spot ink.
let mut inks: Vec<Vec<f32>> = Vec::with_capacity(g.stops.len());
for s in &g.stops {
for (s, &r) in g.stops.iter().zip(&reached) {
let mut v = vec![0.0; 4 + names.len()];
match spot_of(s) {
_ if !r => {}
Some(n) => {
if let Some(i) = names.iter().position(|m| *m == n) {
v[4 + i] = unit(s.tint);
@@ -1116,7 +1120,10 @@ impl<'a> Scene<'a> {
// keep no swatch links: the warning [`Self::ink_stops`] gives the others.
if g.gradient.method.is_hue() && g.gradient.is_spread() && g.gradient.spread == vectorcraft_color::GradientSpread::Reflect {
let doc = self.doc;
if g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) {
let spot = |s: &vectorcraft_color::GradientStop| {
(s.tint > 0.0 || s.plates.is_some()) && s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))
};
if g.gradient.stops.iter().zip(g.gradient.reached_stops()).any(|(s, r)| r && spot(s)) {
self.warn(SPOT_GRADIENTS_HUE);
}
}
+60
View File
@@ -532,6 +532,66 @@ fn a_gradient_mixing_a_spot_ink_with_rgb_is_written_without_the_ink() {
assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings);
}
#[test]
fn gradients_name_only_the_spot_inks_of_stops_that_color_a_stretch() {
let black = |offset: f32| GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 1.0));
let paper = |offset: f32| GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 0.0));
let no_spot_space = |ps: &str| !ps.contains("(Gold Ink) cvn") && !ps.contains("/DeviceN") && !ps.contains("/Separation");
for level in [Level::Two, Level::Three] {
// Gold Ink out of order: the stops at 0 and 0.8 cover the stretches next to it, so it
// colors nothing and has no plate.
let d = spot_gradient_doc(vec![black(0.0), black(0.8), spot_stop(0.2, "Gold Ink", GOLD, 1.0), black(0.4), black(1.0)]);
let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap();
let ps = text(&out);
assert_eq!(dsc(&ps, "DocumentCustomColors"), None, "{level:?}: {ps}");
assert!(no_spot_space(&ps), "{level:?}: {ps}");
assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{level:?}: {:?}", out.warnings);
checked_page(&ps);
// Without it, Leaf Ink's tints over paper are a Separation shading.
let d = spot_gradient_doc(vec![
spot_stop(0.0, "Leaf Ink", LEAF, 1.0),
spot_stop(0.8, "Leaf Ink", LEAF, 0.5),
spot_stop(0.2, "Gold Ink", GOLD, 1.0),
paper(0.4),
paper(1.0),
]);
let ps = text(&export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap());
assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Leaf Ink)"), "{level:?}: {ps}");
assert!(ps.contains("/VCsp0 [/Separation (Leaf Ink) cvn /DeviceCMYK") && !ps.contains("(Gold Ink) cvn"), "{level:?}: {ps}");
checked_page(&ps);
// An RGB stop out of order colors nothing either, so it keeps no ink out.
let d = spot_gradient_doc(vec![
spot_stop(0.0, "Gold Ink", GOLD, 1.0),
spot_stop(0.8, "Gold Ink", GOLD, 0.5),
GradientStop::new(0.2, Color::rgb(0.0, 1.0, 0.0)),
paper(0.4),
paper(1.0),
]);
let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap();
let ps = text(&out);
assert!(ps.contains("/VCsp0 [/Separation (Gold Ink) cvn /DeviceCMYK"), "{level:?}: {ps}");
assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{level:?}: {:?}", out.warnings);
// Gold Ink at a tint of 0 prints no ink.
let d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 0.0), black(1.0)]);
let ps = text(&export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap());
assert_eq!(dsc(&ps, "DocumentCustomColors"), None, "{level:?}: {ps}");
assert!(no_spot_space(&ps), "{level:?}: {ps}");
}
// Gold Ink at a tint of 0 over a 30% cyan plate (a DeviceN gradient read from a PDF) keeps
// its process plate in the same space.
let plated = GradientStop { plates: Some([0.3, 0.0, 0.0, 0.0]), ..spot_stop(1.0, "Gold Ink", GOLD, 0.0) };
let d = spot_gradient_doc(vec![GradientStop::new(0.0, Color::cmyk(0.3, 0.0, 0.0, 0.0)), plated]);
let ps = text(&export(&d, &opts(&d), None, None).unwrap());
assert!(ps.contains("/VCdn0 [/DeviceN [/Cyan (Gold Ink) cvn ] /DeviceCMYK") && checked_page(&ps).contains("/C0 [0.3 0] /C1 [0.3 0]"), "{ps}");
// EPS leaves stop opacity out, so a clear Gold Ink stop between clear stops is written opaque
// and keeps its plate.
let clear = |s: GradientStop| GradientStop { opacity: 0.0, ..s };
let d = spot_gradient_doc(vec![black(0.0), clear(black(0.2)), clear(spot_stop(0.4, "Gold Ink", GOLD, 1.0)), clear(black(0.6)), black(1.0)]);
let ps = text(&export(&d, &opts(&d), None, None).unwrap());
assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Gold Ink)"), "{ps}");
assert!(ps.contains("/VCdn0 [/DeviceN [/Black (Gold Ink) cvn ] /DeviceCMYK"), "{ps}");
}
#[test]
fn spot_inks_named_all_or_none_are_written_under_names_of_their_own() {
let rect = |d: &mut Document, fill: &str, stroke: &str| {
+2 -2
View File
@@ -1264,7 +1264,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.smoothAuto",
"Auto-Smooth",
"",
"{} the open Smooth slider (Object › Path › Smooth and the canvas context menu's Smooth… open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open",
"{} the open Smooth slider (Object › Path › Smooth, the canvas context menu's Smooth… and the Contextual Task Bar's Smooth open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open",
),
(
"ui.simplifyMoreOptions",
@@ -1330,7 +1330,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"file.exportAs",
"Export As…",
"",
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it",
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it",
),
(
"ui.fileInfoDialog",
+64 -28
View File
@@ -90,16 +90,20 @@ impl Exporter<'_> {
/// The stops of a gradient whose every stop is a tint of one spot ink (or paper white, 0% of
/// it) as tints of that ink — a Separation shading — with midpoints as explicit stops (the
/// tint and opacity there are halfway). A gradient that mixes a spot ink with other colors
/// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink.
/// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink that prints
/// ([`Self::printed_spot`]).
fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?;
let drawn = g.drawn_stops();
let ink = g.stops.iter().zip(&drawn).find_map(|(s, &d)| self.printed_spot(s, d))?;
let tints: Option<Vec<f32>> = g
.stops
.iter()
.map(|s| match s.swatch.as_deref() {
.zip(&drawn)
.map(|(s, &d)| match s.swatch.as_deref() {
_ if !d => Some(0.0),
// A stop that also prints process plates needs a DeviceN space.
Some(n) if n == ink && s.plates.is_none() => Some(s.tint),
_ if self.paper_stop(s) => Some(0.0),
_ if self.inkless_stop(s) => Some(0.0),
_ => None,
})
.collect();
@@ -126,19 +130,22 @@ impl Exporter<'_> {
/// the order the stops name them), with midpoints as explicit stops (the tints and opacity
/// there are halfway), or for a Perceptual gradient the stops on its curve
/// ([`vectorcraft_color::Gradient::ink_mixes`]). Paper white adds no ink; a spot stop with
/// process plates ([`vectorcraft_color::GradientStop::plates`]) prints them too. `None`, with a
/// warning, when a stop is written in RGB or Lab or the spot inks can't share a DeviceN space;
/// the gradient is then written in the colors of its stops.
/// process plates ([`vectorcraft_color::GradientStop::plates`]) prints them too. Only the stops
/// that color something that draws ([`vectorcraft_color::Gradient::drawn_stops`]) name inks:
/// the others are written with no ink. `None`, with a warning, when a stop is written in RGB or
/// Lab or the spot inks can't share a DeviceN space; the gradient is then written in the colors
/// of its stops.
fn device_n_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
use krilla::color::device_n::{Color as DnColor, Colorant, DeviceNSpace, Process};
let doc = self.doc;
fn spot_of<'s>(doc: &vectorcraft_doc::Document, s: &'s vectorcraft_color::GradientStop) -> Option<&'s str> {
s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot))
}
let drawn = g.drawn_stops();
// Each spot ink: its swatch's name, then its colorant name and CMYK equivalent.
let mut spots: Vec<(&str, (String, [f32; 4]))> = Vec::new();
for s in &g.stops {
if let Some(n) = spot_of(doc, s)
for (s, &d) in g.stops.iter().zip(&drawn) {
if let Some(n) = self.printed_spot(s, d)
&& !spots.iter().any(|(m, _)| *m == n)
{
spots.push((n, self.spot_ink(n)?));
@@ -146,12 +153,15 @@ impl Exporter<'_> {
}
// Each stop's ink coverage: the four process inks, then one tint per spot ink.
let mut inks: Vec<Vec<f32>> = Vec::with_capacity(g.stops.len());
for s in &g.stops {
for (s, &d) in g.stops.iter().zip(&drawn) {
let mut v = vec![0.0; 4 + spots.len()];
match spot_of(doc, s) {
_ if !d => {}
Some(n) => {
let i = spots.iter().position(|(m, _)| *m == n)?;
v[4 + i] = s.tint;
// A spot ink that no drawn stop prints ([`Self::printed_spot`]) has no colorant.
if let Some(i) = spots.iter().position(|(m, _)| *m == n) {
v[4 + i] = s.tint;
}
if let Some(p) = s.plates {
v[..4].copy_from_slice(&p.map(|t| t.clamp(0.0, 1.0)));
}
@@ -434,18 +444,38 @@ impl Exporter<'_> {
!s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot)) && paper(&s.color)
}
/// The spot swatch whose tints are every stop of `g` that isn't paper white
/// ([`Self::paper_stop`]); `None` when no stop is linked to a spot swatch or a stop has
/// another color.
fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> {
let spot = |s: &'g vectorcraft_color::GradientStop| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot));
let ink = g.stops.iter().find_map(spot)?;
g.stops.iter().all(|s| (spot(s) == Some(ink) && s.plates.is_none()) || self.paper_stop(s)).then_some(ink)
/// The spot swatch stop `s` prints: the one it is linked to, when the stop colors something
/// that draws (`drawn`, [`vectorcraft_color::Gradient::drawn_stops`]) at a tint above 0 or
/// with process plates ([`vectorcraft_color::GradientStop::plates`]), which print in the same
/// DeviceN space.
fn printed_spot<'s>(&self, s: &'s vectorcraft_color::GradientStop, drawn: bool) -> Option<&'s str> {
s.swatch.as_deref().filter(|n| drawn && (s.tint > 0.0 || s.plates.is_some()) && self.doc.swatch(n).is_some_and(|w| w.spot))
}
/// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): is a stop
/// linked to a spot swatch, and is every other stop paper white ([`Self::paper_stop`]) or
/// written in CMYK or gray?
/// Does stop `s` add no ink to a Separation or DeviceN shading: is it paper white
/// ([`Self::paper_stop`]) or linked to a spot swatch at a tint of 0 without process plates?
fn inkless_stop(&self, s: &vectorcraft_color::GradientStop) -> bool {
let spot = s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot));
self.paper_stop(s) || (spot && self.printed_spot(s, true).is_none())
}
/// The spot swatch whose tints are every stop of `g` that colors something that draws
/// ([`vectorcraft_color::Gradient::drawn_stops`]) and adds ink ([`Self::inkless_stop`]); `None`
/// when no such stop prints a spot swatch ([`Self::printed_spot`]) or one has another color.
fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> {
let drawn = g.drawn_stops();
let ink = g.stops.iter().zip(&drawn).find_map(|(s, &d)| self.printed_spot(s, d))?;
g.stops
.iter()
.zip(&drawn)
.all(|(s, &d)| !d || (s.swatch.as_deref() == Some(ink) && s.plates.is_none()) || self.inkless_stop(s))
.then_some(ink)
}
/// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): does a stop
/// print a spot swatch ([`Self::printed_spot`]), and is every other stop that colors something
/// that draws linked to a spot swatch, paper white ([`Self::paper_stop`]) or written in CMYK or
/// gray?
fn device_n_inks_only(&self, g: &vectorcraft_color::Gradient) -> bool {
let doc = self.doc;
let cmyk_doc = doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
@@ -453,7 +483,9 @@ impl Exporter<'_> {
let inks = |s: &vectorcraft_color::GradientStop| {
self.paper_stop(s) || matches!(self.out.color(&s.color, self.intent, cmyk_doc, self.convert), Color::Cmyk { .. } | Color::Gray { .. })
};
g.stops.iter().any(spot) && g.stops.iter().all(|s| spot(s) || inks(s))
let drawn = g.drawn_stops();
let mut stops = g.stops.iter().zip(&drawn);
stops.clone().any(|(s, &d)| self.printed_spot(s, d).is_some()) && stops.all(|(s, &d)| !d || spot(s) || inks(s))
}
/// Is `g` a Perceptual linear or radial gradient of spot inks, alone or mixed with process
@@ -485,16 +517,20 @@ impl Exporter<'_> {
let doc = self.doc;
let full = doc.linked_color(doc.swatch(ink)?.paint.color()?, true);
let space = self.separation(ink)?;
// The stops keep their own tints; paper white is 0% of the ink.
let own: Vec<f32> = g.stops.iter().map(|s| if s.swatch.as_deref() == Some(ink) { s.tint } else { 0.0 }).collect();
// The stops keep their own tints; paper white, and a stop that colors nothing that draws,
// is 0% of the ink.
let own: Vec<f32> =
g.stops.iter().zip(g.drawn_stops()).map(|(s, d)| if d && s.swatch.as_deref() == Some(ink) { s.tint } else { 0.0 }).collect();
Some(g.ink_tints(&full, &own)?.into_iter().map(|(o, t, a)| (o, SepColor::new_f32(t, space.clone()).into(), a)).collect())
}
/// Warn when gradient `g`, drawn as an image, has stops linked to a spot swatch: the image is
/// in process colors, so the spot inks have no plate of their own.
/// Warn when gradient `g`, drawn as an image, has stops that print a spot swatch
/// ([`Self::printed_spot`]): the image is in process colors, so the spot inks have no plate of
/// their own.
fn warn_spot_image(&mut self, g: &vectorcraft_color::GradientPaint) {
let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points();
if from_stops && self.has_spot_stop(&g.gradient) {
let prints_spot = || g.gradient.stops.iter().zip(g.gradient.drawn_stops()).any(|(s, d)| self.printed_spot(s, d).is_some());
if from_stops && prints_spot() {
self.warn(
"spot colors in gradients exported as images (freeform or dithered gradients, ones mixed around the hue wheel, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors",
);
+54
View File
@@ -278,6 +278,60 @@ fn spot_gradients_keep_a_trace_of_process_ink_at_a_light_stop() {
}
}
/// [`gold_gradient_pdf`] with spot swatch "Leaf" (C100 Y100) in the document too.
fn gold_and_leaf_gradient_pdf(stops: Vec<GradientStop>, perceptual: bool) -> String {
let mut d = doc(ColorMode::Cmyk, &[]);
d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true });
d.swatches.push(Swatch { name: "Leaf".into(), paint: Paint::solid(Color::cmyk(1.0, 0.0, 1.0, 0.0)), global: true, spot: true });
let mut g = Gradient::new(GradientKind::Linear, stops);
if perceptual {
g.method = GradientMethod::Perceptual;
}
let paint = Paint::Gradient(Box::new(GradientPaint::new(g)));
let l = d.layers[0].id;
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 30.0)), Appearance::basic(paint, Paint::None, 0.0));
d.insert(Some(l), 0, n).unwrap();
String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned()
}
#[test]
fn gradients_name_only_the_spot_inks_of_stops_that_color_what_draws() {
let black = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 1.0)) };
let paper = |offset: f32| GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 0.0));
let leaf = |offset: f32, tint: f32| {
let mut s = GradientStop::new(offset, Color::cmyk(1.0, 0.0, 1.0, 0.0).tinted(tint));
s.swatch = Some("Leaf".into());
s.tint = tint;
s
};
let clear_gold = GradientStop { opacity: 0.0, ..gold_stop(0.4, 1.0) };
for perceptual in [false, true] {
// Gold in a stretch where every stop is clear, and Gold out of order, where the stops at 0
// and 0.8 cover the stretches next to it: Gold colors nothing, so no Gold plate.
let clear = vec![black(0.0, 1.0), black(0.2, 0.0), clear_gold.clone(), black(0.6, 0.0), black(1.0, 1.0)];
let out_of_order = vec![black(0.0, 1.0), black(0.8, 1.0), gold_stop(0.2, 1.0), black(0.4, 1.0), black(1.0, 1.0)];
// Gold at a tint of 0 prints no Gold ink.
let no_tint = vec![gold_stop(0.0, 0.0), black(1.0, 1.0)];
for (what, stops) in [("clear", clear), ("out of order", out_of_order), ("tint 0", no_tint)] {
let text = gold_gradient_pdf(stops, perceptual);
assert!(!text.contains("/Gold"), "{what}, perceptual {perceptual}: {text}");
assert!(!text.contains("/DeviceN") && !text.contains("/Separation"), "{what}, perceptual {perceptual}: {text}");
}
// Without the Gold stop out of order, Leaf's tints over paper are a Separation shading.
let leaf_only = vec![leaf(0.0, 1.0), leaf(0.8, 0.5), gold_stop(0.2, 1.0), paper(0.4), paper(1.0)];
let text = gold_and_leaf_gradient_pdf(leaf_only, perceptual);
assert!(text.contains("[/Separation/Leaf/") && !text.contains("/Gold") && !text.contains("/DeviceN"), "perceptual {perceptual}: {text}");
// A clear Gold stop next to an opaque one colors the stretch between them.
let text = gold_gradient_pdf(vec![black(0.0, 1.0), clear_gold.clone(), black(1.0, 1.0)], perceptual);
assert!(text.contains("[/DeviceN[/Black/Gold]"), "perceptual {perceptual}: {text}");
}
// Gold at a tint of 0 over a 30% cyan plate (a DeviceN gradient read from a PDF) keeps its
// process plate in the same space.
let plated = GradientStop { plates: Some([0.3, 0.0, 0.0, 0.0]), ..gold_stop(1.0, 0.0) };
let text = gold_gradient_pdf(vec![GradientStop::new(0.0, Color::cmyk(0.3, 0.0, 0.0, 0.0)), plated], false);
assert!(text.contains("[/DeviceN[/Cyan/Gold]"), "{text}");
}
#[test]
fn converting_to_rgb_turns_cmyk_into_rg() {
let ink = Color::cmyk(0.0, 1.0, 1.0, 0.0);
+3 -3
View File
@@ -951,7 +951,8 @@ fn paints(ap: &Appearance, fill: bool) -> Vec<&Paint> {
}
/// `p` without swatch links: a plug-in's colours are its own, so only paints it returned
/// unchanged keep theirs.
/// unchanged keep theirs. A gradient stop also loses its process plates, which print only with
/// a spot swatch.
fn unlinked(mut p: Paint) -> Paint {
match &mut p {
Paint::Solid { swatch, tint, .. } => {
@@ -961,8 +962,7 @@ fn unlinked(mut p: Paint) -> Paint {
Paint::Gradient(g) => {
g.swatch = None;
for s in &mut g.gradient.stops {
s.swatch = None;
s.tint = 1.0;
s.set_color(s.color, None);
}
}
Paint::None | Paint::Pattern { .. } => {}
+61 -7
View File
@@ -109,27 +109,81 @@ fn only_a_radial_keeps_a_focal_point_through_a_plug_in() {
}
}
/// A gradient stop's process plates handed back out of range are clamped to 0..1, each in its
/// A gradient stop's process plates handed back out of range are read clamped to 0..1, each in its
/// place, in a fill and in a stroke (a JSON number too large for `f32` is infinite); plates in range
/// and a stop without plates are kept.
#[test]
fn gradient_stop_plates_are_clamped_through_a_plug_in() {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
let (mut d, ids, _) = doc();
let (_, ids, _) = doc();
let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(0.5, Color::gray(0.5)), GradientStop::new(1.0, Color::BLACK)];
let mut p = serde_json::to_value(Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))))).unwrap();
p["gradient"]["stops"][0]["plates"] = json!([-0.5, 1.5, -1e300, 1e300]);
p["gradient"]["stops"][1]["plates"] = json!([0.25, 2.0, 0.75, -3.0]);
for (i, plates) in [(0, json!([-0.5, 1.5, -1e300, 1e300])), (1, json!([0.25, 2.0, 0.75, -3.0]))] {
p["gradient"]["stops"][i]["plates"] = plates;
p["gradient"]["stops"][i]["swatch"] = json!("Spot");
}
let out = json!({"objects": [{"id": ids[0].0, "fills": [p.clone()], "strokes": [{"paint": p}]}]});
apply_output(&mut d, &ids, decode_json(out).unwrap(), None, &keep).unwrap();
let ap = &d.node(ids[0]).unwrap().appearance;
for (what, paint) in [("fill", ap.fill_paint()), ("stroke", ap.stroke_paint())] {
let out = decode_json(out).unwrap();
let fill = &out[0].fills.as_ref().unwrap()[0];
let stroke = &out[0].strokes.as_ref().unwrap()[0].paint;
for (what, paint) in [("fill", fill), ("stroke", stroke)] {
let Paint::Gradient(g) = paint else { panic!("a gradient {what}") };
let plates: Vec<_> = g.gradient.stops.iter().map(|s| s.plates).collect();
assert_eq!(plates, [Some([0.0, 1.0, 0.0, 1.0]), Some([0.25, 1.0, 0.75, 0.0]), None], "{what}");
}
}
/// A gradient linked to a gradient swatch, whose first stop is linked to a spot swatch at a tint
/// with process plates.
fn spot_gradient(color: Color) -> Paint {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
let mut spot = GradientStop::new(0.0, color);
spot.midpoint = 0.3;
spot.opacity = 0.75;
spot.set_color(color, Some(("Spot".into(), 0.4)));
spot.plates = Some([0.1, 0.2, 0.3, 0.4]);
let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![spot, GradientStop::new(1.0, Color::BLACK)]));
g.swatch = Some("Blend".into());
Paint::Gradient(Box::new(g))
}
/// A changed gradient handed back loses each stop's spot link, tint and process plates, in a fill
/// and in a stroke, and keeps the stop's color, offset, opacity and midpoint; a new stop handed back
/// with plates and no link has none.
#[test]
fn changed_gradient_fill_and_stroke_drop_spot_plates() {
let (mut d, ids, _) = doc();
let a = ids[0];
let was = Color::cmyk(0.0, 0.5, 1.0, 0.0);
d.node_mut(a).unwrap().appearance.set_fill(spot_gradient(was));
d.node_mut(a).unwrap().appearance.set_stroke(spot_gradient(was));
let now = Color::cmyk(0.0, 0.25, 0.5, 0.0);
let mut p = serde_json::to_value(spot_gradient(now)).unwrap();
p["gradient"]["stops"][1]["plates"] = json!([0.5, 0.5, 0.5, 0.5]);
let out = json!({"objects": [{"id": a.0, "fills": [p.clone()], "strokes": [{"paint": p}]}]});
apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &keep).unwrap();
let ap = &d.node(a).unwrap().appearance;
for (what, paint) in [("fill", ap.fill_paint()), ("stroke", ap.stroke_paint())] {
let Paint::Gradient(g) = paint else { panic!("a gradient {what}") };
assert_eq!(g.swatch, None, "{what}");
let stops: Vec<_> = g.gradient.stops.iter().map(|s| (s.offset, s.color, s.opacity, s.midpoint, s.swatch.clone(), s.tint, s.plates)).collect();
assert_eq!(stops, [(0.0, now, 0.75, 0.3, None, 1.0, None), (1.0, Color::BLACK, 1.0, 0.5, None, 1.0, None)], "{what}");
}
}
/// A gradient handed back as it was keeps its stops' spot links, tints and process plates.
#[test]
fn unchanged_gradient_paint_keeps_spot_plates() {
let (mut d, ids, _) = doc();
let a = ids[0];
d.node_mut(a).unwrap().appearance.set_fill(spot_gradient(Color::cmyk(0.0, 0.5, 1.0, 0.0)));
d.node_mut(a).unwrap().appearance.set_stroke(spot_gradient(Color::cmyk(0.0, 0.5, 1.0, 0.0)));
let input: Value = serde_json::from_slice(&encode_input(&d, &[a])).unwrap();
let before = d.clone();
apply_output(&mut d, &[a], decode(&serde_json::to_vec(&input).unwrap()).unwrap(), None, &keep).unwrap();
assert_eq!(d, before);
}
/// A plate that is not a number is 0; infinities go to the nearest end of 0..1.
#[test]
fn plates_that_are_not_finite_are_in_range() {
+20 -2
View File
@@ -623,7 +623,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b
let art = effects::evaluate_container(Some(symbols.document()), n);
let art = art.as_ref().unwrap_or(n);
let b = if art.clips() {
art.visual_bounds_with(symbols)
art.visual_bounds_with(symbols).map(|b| with_clip_region(symbols, art, b))
} else {
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, member(c)))
}?;
@@ -671,7 +671,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b
let b = n.visual_bounds_with(symbols)?;
let b = match &n.kind {
NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b),
_ => b,
_ => with_clip_region(symbols, n, b),
};
let a = live_content_reach(symbols, n);
Some(b.inflate(a, a))
@@ -679,6 +679,24 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b
}
}
/// `b`, the visual bounds of `n`, grown to the region a clip group's or clipping layer's clipping
/// path clips to when geometry effects in it move or grow that region ([`effects::clip_outline`])
/// past its plain shape, and to where its stroke paints along it. Other objects keep `b`.
fn with_clip_region(symbols: &SymbolBoxes, n: &Node, b: Rect) -> Rect {
match &n.kind {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => match children.first() {
Some(c) if effects::needs_bake(c) => {
let s = c.appearance.outset();
let region = effects::clip_outline(c).map(|(bp, _)| bp.bounding_box().inflate(s, s));
let painted = if has_fx(c) { visual_bounds(symbols, c) } else { None };
vectorcraft_geom::union_opt(vectorcraft_geom::union_opt(Some(b), region), painted).unwrap_or(b)
}
_ => b,
},
_ => b,
}
}
/// The bounds of the art a 3D effect of `n` draws (Revolve, Extrude & Bevel, Inflate, Rotate),
/// built now, without `n`'s own raster effects ([`three_d_art_bounds`]): `None` when it draws no
/// such art, `Some(None)` when that art paints nothing. The renderer measures the art it keeps
+4 -1
View File
@@ -122,7 +122,8 @@ impl Renderer {
/// When a plug-in has been installed or removed since the last check
/// ([`effects::plugin_revision`]), drop what the caches keep from plug-in effects: the effect
/// geometry of paths with a plug-in effect, the cull bounds of objects that have one or hold one,
/// of symbol instances and of the objects that hold them, and the art drawn for the effects of
/// of symbol instances and of the objects that hold them, the regions of clipping paths that
/// have one or hold one, and the art drawn for the effects of
/// objects that have a plug-in effect or hold one. Rasters of raster effects and evaluations of live objects hold
/// the revision in their keys when what they draw has a plug-in effect (`fx::Inputs`,
/// `live::resources_key`).
@@ -148,6 +149,8 @@ impl Renderer {
self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node));
// The art of a group holds what its members' plug-in effects draw.
self.fx_arts.retain(|(node, ..)| !holds_plugin_effect(node));
// A clipping path's plug-in effects shape the region it clips to.
self.clips.retain(|(node, _)| !holds_plugin_effect(node));
}
}
+73
View File
@@ -95,6 +95,79 @@ fn nothing_to_clip_by_hides_the_art() {
assert_eq!(r.pixel(50, 50), CLEAR);
}
#[test]
fn the_clipping_paths_geometry_effects_shape_the_clip() {
// A Transform effect moving the left half 50 pt right: the right half shows instead.
let mut d = Document::new(100.0, 100.0);
let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
clip.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true });
let r = clipped(d, Arc::new(clip));
assert_eq!(r.pixel(25, 50), CLEAR, "the clipping path's own place");
assert_eq!(r.pixel(75, 50), BLACK, "where its effect moves it");
}
#[test]
fn a_view_of_only_the_moved_clip_draws_the_clipped_art() {
// The clipping path covers x 0 to 50 and its Transform effect moves it to x 50 to 100; the
// view shows x 60 to 100, past the clipping path's own shape.
let mut d = Document::new(100.0, 100.0);
let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
clip.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true });
let art = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let members = vec![Arc::new(clip), Arc::new(art)];
let g = Node::new(d.alloc_id(), NodeKind::Group { children: members.clone(), clip: true });
// The same in a sublayer that clips by its first object.
let mut sublayer = (*d.layers[0]).clone();
sublayer.id = d.alloc_id();
if let NodeKind::Layer { children, clip, .. } = &mut sublayer.kind {
(*children, *clip) = (members, true);
}
for clipping in [g, sublayer] {
let mut d = d.clone();
let l = d.layers[0].id;
d.insert(Some(l), 0, clipping).unwrap();
let r = Renderer::new().render(&d, 40, 100, Affine::translate((-60.0, 0.0)), &RenderOptions::default());
assert_eq!(r.pixel(20, 50), BLACK, "x 80, inside the moved clip");
}
}
#[test]
fn a_view_of_only_the_moved_clip_draws_a_clip_group_with_its_own_appearance() {
// As above, with the Transform effect on the clipping path itself or on its fill, and the clip
// group with an effect of its own: a Drop Shadow, or a Transform that moves nothing.
let moved = || vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true };
let shadow = vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 0, "y": 6, "blur": 0, "opacity": 100}),
visible: true,
};
let still = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveV": 0}), visible: true };
for on_fill in [false, true] {
for own in [shadow.clone(), still.clone()] {
let mut d = Document::new(100.0, 100.0);
let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
match clip.appearance.items.iter_mut().find_map(|i| match i {
vectorcraft_doc::AppearanceItem::Fill(f) if on_fill => Some(f),
_ => None,
}) {
Some(f) => f.effects.push(moved()),
None => clip.appearance.effects.push(moved()),
}
let art =
Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let mut g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true });
g.appearance.effects.push(own.clone());
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let what = format!("effect on the fill: {on_fill}, group effect {}", own.id);
let whole = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(whole.pixel(80, 50), BLACK, "the whole canvas ({what})");
let r = Renderer::new().render(&d, 40, 100, Affine::translate((-60.0, 0.0)), &RenderOptions::default());
assert_eq!(r.pixel(20, 50), BLACK, "x 80, inside the moved clip ({what})");
}
}
}
#[test]
fn clip_regions_are_cached_per_clipping_path() {
let mut d = Document::new(100.0, 100.0);
+3 -3
View File
@@ -217,9 +217,9 @@ fn type_in_a_group_keeps_its_transparency_and_visibility_under_the_groups_geomet
#[test]
fn type_in_a_clipping_path_clips_alike_under_the_groups_geometry_effects() {
// The red square is clipped by type with a Transform of its own, which the clip leaves out,
// then by a group of that type and a small square, with the type shown and hidden. Offset
// Path 0 on the clip group leaves every path as it is.
// The red square is clipped by type with a Transform of its own, then by a group of that type
// and a small square, with the type shown and hidden. Offset Path 0 on the clip group leaves
// every path as it is, so the clip group draws the same with it and without it.
let doc = |effect: bool, grouped: bool, hidden: bool| {
let mut d = Document::new(100.0, 100.0);
let style = CharStyle { size: 40.0, fill: Paint::None, ..Default::default() };
+10
View File
@@ -1667,6 +1667,16 @@ fn objects_with_plugin_effects_follow_the_plugins_installed() {
plugin_change_draws_as_new(alone(20.0, vec![drop_shadow()]), ID, small.clone(), big.clone());
let blur = effect("blur.gaussian", serde_json::json!({"radius": 1.0}));
plugin_change_draws_as_new(alone(20.0, vec![blur]), ID, small.clone(), big.clone());
// A clip group whose clipping path, the square, has the plug-in effect: it clips a red
// rectangle over the whole view by the shape the plug-in draws.
let mut d = Document::new(200.0, 100.0);
let mut clip = square(&mut d, 20.0, 30.0, vec![]);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let red = filled(&mut d, rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), vec![]);
let group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(red)], clip: true });
plugin_change_draws_as_new(with_art(d, vec![group]), ID, small.clone(), big.clone());
// An instance of symbol `Shaped`, a group of the square around the origin.
let mut d = Document::new(200.0, 100.0);
let art = square(&mut d, -20.0, -20.0, vec![]);
+2
View File
@@ -31,6 +31,8 @@ image = { workspace = true }
# Colour / transform / length parsing for the <text> fallback (MIT OR Apache-2.0; already used by usvg).
svgtypes = "0.16"
simplecss = "0.2.2"
# Bidi classes for the first strong character of `unicode-bidi: plaintext` text (MIT OR Apache-2.0; already used by vectorcraft-text).
unicode-bidi = "0.3.18"
[dev-dependencies]
# Reading embedded font subsets back.
+11 -5
View File
@@ -283,6 +283,11 @@ impl Writer<'_> {
self.warn("type on a path with Insert Aki is outlined to keep its look");
return self.text_outlines(n, t);
}
// Tsume does as well.
if t.runs.iter().any(|r| r.style.tsume != 0.0) {
self.warn("type on a path with Tsume is outlined to keep its look");
return self.text_outlines(n, t);
}
// A `<textPath>` holds the characters of the text, not the bullets or numbers drawn
// before them.
if !lay.markers.is_empty() {
@@ -715,13 +720,13 @@ const PLACE_TOLERANCE: f64 = 1e-6;
/// starts a new segment at each character the layout draws elsewhere than a reader continuing
/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between
/// the characters of a justified Japanese line, and the spacing Mojikumi changes. Text with Insert
/// Aki does so on every line, with `fewer` too.
/// Aki or Tsume does so on every line, with `fewer` too.
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Line> {
let plain = t.plain_text();
let mut lines = Vec::with_capacity(lay.lines.len());
let (mut kerned, mut unkerned) = (None, None);
// Insert Aki moves characters from where a reader draws them, with `fewer` too.
let aki = t.runs.iter().any(|r| r.style.aki_left != 0.0 || r.style.aki_right != 0.0);
// Insert Aki and Tsume move characters from where a reader draws them, with `fewer` too.
let aki = t.runs.iter().any(|r| r.style.aki_left != 0.0 || r.style.aki_right != 0.0 || r.style.tsume != 0.0);
for line in &lay.lines {
let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count());
let style = t.para_at(para);
@@ -885,11 +890,12 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning:
if !kerning {
r.style.kerning.get_or_insert(0.0);
}
// A reader puts no space before a line's first character (`kerningBefore`) and no Insert
// Aki: the segment starts that much further along instead.
// A reader puts no space before a line's first character (`kerningBefore`), no Insert
// Aki and no Tsume: the segment starts that much further along instead.
r.style.kerning_before = 0.0;
r.style.aki_left = 0.0;
r.style.aki_right = 0.0;
r.style.tsume = 0.0;
}
let relaid;
let glyphs = if set == *t {
+95 -20
View File
@@ -711,9 +711,9 @@ struct Ctx {
sizes: RefCell<HashMap<roxmltree::NodeId, f64>>,
weights: RefCell<HashMap<roxmltree::NodeId, u16>>,
elems: RefCell<HashMap<roxmltree::NodeId, Rc<ElemStyle>>>,
/// Whether the first strong character of each element read so far is right to left
/// ([`Self::first_strong_rtl`]); `None` where it holds none.
strong: RefCell<HashMap<roxmltree::NodeId, Option<bool>>>,
/// The first strong characters of each `<text>` read so far, by the `<text>`
/// ([`Self::first_strong_rtl`]).
strong: RefCell<HashMap<roxmltree::NodeId, Rc<FirstStrong>>>,
/// The path each `<textPath>` read so far follows ([`Self::text_path`]); `None` where it has
/// no usable one. The styles of a `<use>` instance share it, so each path is found and parsed
/// once however many `<use>` elements show its text.
@@ -1006,22 +1006,39 @@ impl Ctx {
}
/// Whether the first strong character (UAX #9 P2) of the characters [`Self::collect`] reads
/// from element `n` is right to left; `None` when they hold none. Each element is read once.
/// from element `n` is right to left; `None` when they hold none. The characters of the whole
/// `<text>` are read once, as one sequence, so an isolate that opens in one text node and
/// closes in a later one is skipped whole ([`FirstStrong`]).
fn first_strong_rtl(&self, n: XNode) -> Option<bool> {
if let Some(s) = self.strong.borrow().get(&n.id()) {
return *s;
// The `<text>` (or outermost element) whose characters hold those of `n`.
let mut root = n;
while let Some(p) = root.parent_element().filter(|_| root.tag_name().name() != "text" && self.is_inline_child(root)) {
root = p;
}
let s = n.children().find_map(|c| {
if c.is_text() {
text_first_strong_rtl(c.text().unwrap_or(""))
} else if self.is_inline_child(c) {
self.first_strong_rtl(c)
} else {
None
}
let strong = self.strong.borrow().get(&root.id()).cloned();
let strong = strong.unwrap_or_else(|| {
let mut chars = vec![];
let mut spans = HashMap::new();
self.inline_chars(root, &mut chars, &mut spans);
let s = Rc::new(FirstStrong::new(&chars, spans));
self.strong.borrow_mut().insert(root.id(), s.clone());
s
});
self.strong.borrow_mut().insert(n.id(), s);
s
strong.of(n.id())
}
/// Appends the characters of element `n` and of its shown inline children to `chars`, in
/// document order, and the range each of those elements holds to `spans`.
fn inline_chars(&self, n: XNode, chars: &mut Vec<char>, spans: &mut HashMap<roxmltree::NodeId, Range<usize>>) {
let start = chars.len();
for c in n.children() {
if c.is_text() {
chars.extend(c.text().unwrap_or("").chars());
} else if self.is_inline_child(c) {
self.inline_chars(c, chars, spans);
}
}
spans.insert(n.id(), start..chars.len());
}
/// Whether child `c` of a `<text>` content element holds characters of the text: a shown
@@ -1408,10 +1425,18 @@ impl Ctx {
let em = self.font_size(tp);
let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0];
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
// Text that would start before the path's beginning starts at it, with all its glyphs
// on the path (SVG does not draw the glyphs that fall off it).
let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0));
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start, end: None };
// On a path of one closed subpath, text that starts before the path's start or past its
// end wraps around the path. Otherwise it starts at the path's start or end.
let path = PathData::from_bezpath(&bp);
let wraps = path.subpaths.len() == 1 && path.is_closed();
let wrap = |s: f64| {
// `rem_euclid` can round a tiny negative value up to 1.
let s = s.rem_euclid(1.0);
if s < 1.0 { s } else { 0.0 }
};
let start =
Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| if wraps { wrap(s) } else { s.clamp(0.0, 1.0) });
obj.kind = TextKind::OnPath { path, start, end: None };
obj.xf = Affine::IDENTITY;
} else if vertical {
// Type on a vertical path: dy is extra advance, dx shifts across the column.
@@ -1774,6 +1799,56 @@ fn text_first_strong_rtl(text: &str) -> Option<bool> {
(!vectorcraft_text::paragraph_is_rtl(&format!("{line}\u{5D0}"), None)).then_some(false)
}
/// The first strong characters (UAX #9 P2) of the elements of one `<text>`, found for each element
/// in constant time from one pass over the text's characters. An isolate initiator is skipped to
/// its matching PDI (UAX #9 BD9), and one without a matching PDI to the end; paragraph separators
/// count as spaces, as in [`text_first_strong_rtl`].
struct FirstStrong {
/// The characters each element holds: a range of indices into `next`.
spans: HashMap<roxmltree::NodeId, Range<usize>>,
/// For each character and for the end: the index of the first strong character from there on,
/// isolates skipped, and whether it is right to left; `None` when there is none.
next: Vec<Option<(usize, bool)>>,
}
impl FirstStrong {
fn new(chars: &[char], spans: HashMap<roxmltree::NodeId, Range<usize>>) -> Self {
use unicode_bidi::BidiClass::{AL, FSI, L, LRI, PDI, R, RLI};
let class = |c: char| unicode_bidi::bidi_class(c);
// The matching PDI of each isolate initiator.
let mut pdi = vec![None; chars.len()];
let mut open = vec![];
for (i, &c) in chars.iter().enumerate() {
match class(c) {
LRI | RLI | FSI => open.push(i),
PDI => {
if let Some(at) = open.pop() {
pdi[at] = Some(i);
}
}
_ => {}
}
}
let mut next = vec![None; chars.len() + 1];
for (i, &c) in chars.iter().enumerate().rev() {
next[i] = match class(c) {
L => Some((i, false)),
R | AL => Some((i, true)),
LRI | RLI | FSI => pdi[i].and_then(|p| next[p + 1]),
_ => next[i + 1],
};
}
Self { spans, next }
}
/// Whether the first strong character of element `n` is right to left; `None` when it holds
/// none. An isolate that opens inside `n` and closes after it hides the rest of `n`.
fn of(&self, n: roxmltree::NodeId) -> Option<bool> {
let span = self.spans.get(&n)?;
self.next.get(span.start).copied().flatten().filter(|&(at, _)| at < span.end).map(|(_, rtl)| rtl)
}
}
/// A left-aligned point text of `runs` at the origin (for measuring).
fn with_runs(runs: Vec<TextRun>) -> TextObject {
let mut t = TextObject::point(Point::ZERO, "", CharStyle::default());
+31
View File
@@ -102,6 +102,37 @@ fn a_strokes_own_glow_filters_the_stroke_element_only() {
assert!(!export(&doc_with(n), &ExportOptions::default()).contains("<filter "));
}
#[test]
fn a_clipping_paths_transform_effect_moves_the_written_clip_path() {
// A clip group whose clipping path covers x 0 to 50; `effect` moves it 50 pt right.
let clipped = |x0: f64, effect: bool| {
let mut clip =
Node::path(NodeId(0), shapes::rectangle(Rect::new(x0, 0.0, x0 + 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
if effect {
clip.appearance.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 50}), visible: true });
}
let art = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
let mut d = Document::new(200.0, 200.0);
let (clip, art) = (Node { id: d.alloc_id(), ..clip }, Node { id: d.alloc_id(), ..art });
let g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), std::sync::Arc::new(art)], clip: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let svg = export(&d, &ExportOptions::default());
let open = svg.find("<clipPath").expect("a clip path");
let body = &svg[open..open + svg[open..].find("</clipPath>").expect("its end")];
body[body.find('>').unwrap()..].to_string()
};
assert_eq!(clipped(0.0, true), clipped(50.0, false), "the clip path is written where the effect moves it");
assert_ne!(clipped(0.0, true), clipped(0.0, false));
}
#[test]
fn geometry_effects_on_type_export_as_its_reshaped_outlines() {
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };

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