mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 07:42:42 +00:00
Merge main into fix/tr-message-rows
This commit is contained in:
Generated
+98
-28
@@ -978,11 +978,33 @@ dependencies = [
|
||||
"ruzstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-codepage"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
|
||||
|
||||
[[package]]
|
||||
name = "craft-dxf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
dependencies = [
|
||||
"craft-codepage",
|
||||
"encoding_rs",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-eps"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
@@ -1057,6 +1079,21 @@ dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-svg"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"flate2",
|
||||
"kurbo 0.13.1",
|
||||
"roxmltree 0.21.2",
|
||||
"thiserror 2.0.21",
|
||||
"usvg 0.48.1",
|
||||
"vectorcraft-geom 0.18.0",
|
||||
"vectorcraft-pathops 0.18.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1146,6 +1183,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xml"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.3"
|
||||
@@ -6386,13 +6432,14 @@ dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-dxf",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -6400,8 +6447,8 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
@@ -6418,7 +6465,7 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-mcp",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
@@ -6455,7 +6502,7 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
|
||||
@@ -6470,8 +6517,8 @@ dependencies = [
|
||||
"svgtypes 0.16.1",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-three-d",
|
||||
@@ -6515,10 +6562,10 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-eps",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-js",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
@@ -6537,6 +6584,7 @@ dependencies = [
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-eps",
|
||||
"craft-pdf",
|
||||
"encoding_rs",
|
||||
"fax",
|
||||
@@ -6552,9 +6600,9 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-testkit",
|
||||
@@ -6573,10 +6621,20 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.18.0"
|
||||
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.24.1"
|
||||
@@ -6611,7 +6669,7 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
@@ -6628,10 +6686,21 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.18.0"
|
||||
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom 0.18.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.24.1"
|
||||
@@ -6640,7 +6709,7 @@ dependencies = [
|
||||
"linesweeper",
|
||||
"proptest",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-testkit",
|
||||
]
|
||||
|
||||
@@ -6673,7 +6742,7 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"weezl 0.2.1",
|
||||
@@ -6692,7 +6761,7 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"wasmi",
|
||||
"wat",
|
||||
"web-time",
|
||||
@@ -6717,7 +6786,7 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
@@ -6741,6 +6810,7 @@ name = "vectorcraft-svg"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"craft-svg",
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6757,8 +6827,8 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
@@ -6776,7 +6846,7 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
]
|
||||
@@ -6796,7 +6866,7 @@ dependencies = [
|
||||
"unicode-script",
|
||||
"unicode-segmentation",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vello_cpu",
|
||||
"windows-registry",
|
||||
"write-fonts",
|
||||
@@ -6806,8 +6876,8 @@ dependencies = [
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6822,8 +6892,8 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
@@ -6836,7 +6906,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6865,7 +6935,7 @@ dependencies = [
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-eps",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
|
||||
@@ -25,6 +25,12 @@ craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", re
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
# Native Affinity documents (craft-libs#914).
|
||||
craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" }
|
||||
# SVG parsing, limits, SVGZ and writer (craft-libs#778).
|
||||
craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2", features = ["tree"] }
|
||||
# EPS container, previews, ASCII85 and DSC comments (craft-libs#778).
|
||||
craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" }
|
||||
# DXF tags, reader and writer (craft-libs#778, #926, #927).
|
||||
craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
|
||||
@@ -373,11 +373,14 @@ fn main() -> std::process::ExitCode {
|
||||
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151),
|
||||
// and the other choices do so when a pen is attached, where the pen draws (#2028, #4967).
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut forced_to_x11 = false;
|
||||
#[cfg(target_os = "linux")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
|
||||
if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) {
|
||||
if xwayland::x11_libraries_found() {
|
||||
forced_to_x11 = true;
|
||||
if pref != Some("x11") {
|
||||
log::info!("pen found: opening the window through XWayland, where the pen works (VECTORCRAFT_NATIVE_WAYLAND=1 keeps Wayland)");
|
||||
} else {
|
||||
@@ -393,6 +396,11 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
options
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let forced_to_x11 = false;
|
||||
// Preferences › User Interface notes under System Title Bar when the desktop draws the frame
|
||||
// with it on or off.
|
||||
let desktop_keeps_frame = titlebar::desktop_keeps_frame(forced_to_x11);
|
||||
// With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end
|
||||
// is down; the message hook notes each pointer contact and whether it starts with the barrel
|
||||
// button held, and writes the pen's end into the pen feed (`windows_stylus`). It also notes the
|
||||
@@ -595,6 +603,7 @@ fn main() -> std::process::ExitCode {
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
app.caption_layout = caption_layout;
|
||||
app.desktop_frame = !custom_titlebar;
|
||||
app.desktop_keeps_frame = desktop_keeps_frame;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx).with_automation_roots(roots.clone());
|
||||
|
||||
@@ -346,6 +346,34 @@ fn the_titlebar_variable_decides_the_frame_before_the_window_opens() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Preferences says under System Title Bar that the desktop draws the frame with it on or off
|
||||
/// exactly where the start frame without VectorCraft Title Bar on Every Desktop is the desktop's
|
||||
/// with System Title Bar off as well as on and `VECTORCRAFT_TITLEBAR` is unset: Linux and the BSDs
|
||||
/// only.
|
||||
#[test]
|
||||
fn the_desktop_keeps_the_frame_where_system_title_bar_does_not_change_it() {
|
||||
use crate::titlebar::desktop_keeps_frame_from;
|
||||
use craft_window::{Frame, Layout};
|
||||
let linux = cfg!(not(any(target_os = "macos", target_os = "windows")));
|
||||
for probed in [Frame::Desktop, Frame::App(Layout::default())] {
|
||||
for forced_to_x11 in [false, true] {
|
||||
let frame = super::start_frame(probed.clone(), None, forced_to_x11, false);
|
||||
let either_way = !super::custom_titlebar_from(&frame, false) && !super::custom_titlebar_from(&frame, true);
|
||||
assert_eq!(desktop_keeps_frame_from(&probed, None, forced_to_x11), linux && either_way, "{probed:?} {forced_to_x11}");
|
||||
}
|
||||
}
|
||||
// KDE, Sway and X11, and GNOME through XWayland, keep the desktop's frame; GNOME on Wayland does not.
|
||||
assert_eq!(desktop_keeps_frame_from(&Frame::Desktop, None, false), linux);
|
||||
assert_eq!(desktop_keeps_frame_from(&Frame::App(Layout::default()), None, true), linux);
|
||||
assert!(!desktop_keeps_frame_from(&Frame::App(Layout::default()), None, false));
|
||||
// While `VECTORCRAFT_TITLEBAR` chooses the frame, System Title Bar does not apply and there is no note.
|
||||
for (frame, overridden) in [(Frame::App(Layout::default()), Some(true)), (Frame::Desktop, Some(false))] {
|
||||
for forced_to_x11 in [false, true] {
|
||||
assert!(!desktop_keeps_frame_from(&frame, overridden, forced_to_x11), "{overridden:?} {forced_to_x11}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file extensions the macOS bundle declares: its document types and its own exported type.
|
||||
fn plist_extensions(plist: &str) -> Vec<&str> {
|
||||
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
|
||||
|
||||
@@ -31,7 +31,7 @@ pub(crate) fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
|
||||
format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
|
||||
/// Windows: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
|
||||
/// macOS keeps its traffic lights over the integrated title strip.
|
||||
///
|
||||
/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the
|
||||
@@ -191,6 +191,25 @@ pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> (
|
||||
(custom_titlebar_from(&frame, opted_into_system), caption_layout_from(&frame))
|
||||
}
|
||||
|
||||
/// Whether the desktop would draw this window's frame with System Title Bar on or off while
|
||||
/// VectorCraft Title Bar on Every Desktop is off: `VECTORCRAFT_TITLEBAR` is not set to `app`,
|
||||
/// `system` or `desktop` (`overridden` is `None`), and the probe chose
|
||||
/// [`craft_window::Frame::Desktop`] or the window opens through XWayland (`forced_to_x11`)
|
||||
/// ([`app_draws_frame`]). The saved VectorCraft Title Bar on Every Desktop is left out, so the value
|
||||
/// stays `true` on such a desktop while that preference gives the app the frame. Always `false` on
|
||||
/// macOS and Windows, where System Title Bar decides the frame by itself.
|
||||
pub(crate) fn desktop_keeps_frame_from(probed: &craft_window::Frame, overridden: Option<bool>, forced_to_x11: bool) -> bool {
|
||||
let probed_app = matches!(probed, craft_window::Frame::App(_));
|
||||
cfg!(not(any(target_os = "macos", target_os = "windows"))) && overridden.is_none() && !app_draws_frame(probed_app, None, forced_to_x11, false)
|
||||
}
|
||||
|
||||
/// [`desktop_keeps_frame_from`] for this start's probe, given whether the launch path opens the
|
||||
/// window through XWayland.
|
||||
pub(crate) fn desktop_keeps_frame(forced_to_x11: bool) -> bool {
|
||||
let choice = probed_choice();
|
||||
desktop_keeps_frame_from(choice.frame(), choice.overridden(), forced_to_x11)
|
||||
}
|
||||
|
||||
pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
|
||||
@@ -114,51 +114,9 @@ fn strip_brushes(n: &mut Node) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mix every solid paint under `n` towards `color` by `amount` (0..1) — the Symbol Stainer.
|
||||
pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
|
||||
let amount = amount.clamp(0.0, 1.0);
|
||||
for it in &mut n.appearance.items {
|
||||
let p = match it {
|
||||
AppearanceItem::Fill(f) => &mut f.paint,
|
||||
AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Solid { color: c, .. } = p {
|
||||
*p = Paint::solid(c.lerp(color, amount));
|
||||
}
|
||||
}
|
||||
if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
for p in [&mut r.style.fill, &mut r.style.stroke] {
|
||||
if let Paint::Solid { color: c, .. } = p {
|
||||
*p = Paint::solid(c.lerp(color, amount));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
tint_node(Arc::make_mut(c), color, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stain instance `inst` puts on its symbol's art: its visible fill colour and that fill's
|
||||
/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one
|
||||
/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links.
|
||||
pub fn stain(inst: &Node) -> Option<(Color, f32)> {
|
||||
let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?;
|
||||
Some((f.paint.color()?, f.opacity))
|
||||
}
|
||||
|
||||
/// A symbol's art for instance `inst`: with the instance's own appearance changes to it (a dynamic
|
||||
/// symbol's, [`vectorcraft_doc::symbol_overrides`]), stained by the instance's fill ([`stain`]).
|
||||
pub fn instance_art(art: &Node, inst: &Node) -> Node {
|
||||
let mut a = vectorcraft_doc::symbol_overrides::dynamic_art(art, inst);
|
||||
if let Some((c, amount)) = stain(inst) {
|
||||
tint_node(&mut a, &c, amount);
|
||||
}
|
||||
a
|
||||
}
|
||||
/// The Symbol Stainer's mix, an instance's stain and its stained art, from
|
||||
/// [`vectorcraft_doc::symbol_overrides`], which the effects that reshape an instance also read.
|
||||
pub use vectorcraft_doc::symbol_overrides::{instance_art, stain, tint_node};
|
||||
|
||||
/// The solid colours of the fills and strokes under `nodes`, most used first (then in the order
|
||||
/// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-dxf = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
|
||||
+4
-67
@@ -1,50 +1,11 @@
|
||||
//! The 256-colour index palette CAD apps share, generated rather than tabulated: indices 1–9 are
|
||||
//! the standard colours, 10–249 run through 24 hues at five brightnesses each in a full and a
|
||||
//! half-saturated tone, and 250–255 are greys.
|
||||
//! The colour index palette is `craft_dxf::model::aci`; here are the indices each colour depth
|
||||
//! of the writer may use.
|
||||
|
||||
use crate::ColorDepth;
|
||||
|
||||
/// The nine standard colours (index 7 shows white on a dark background and black on paper).
|
||||
const STANDARD: [[u8; 3]; 9] =
|
||||
[[255, 0, 0], [255, 255, 0], [0, 255, 0], [0, 255, 255], [0, 0, 255], [255, 0, 255], [255, 255, 255], [128, 128, 128], [192, 192, 192]];
|
||||
|
||||
/// The brightness of the five shades of each hue.
|
||||
const SHADES: [f64; 5] = [1.0, 0.65, 0.5, 0.3, 0.15];
|
||||
|
||||
/// The RGB of colour index `i` (1–255; anything else is index 7's white).
|
||||
pub fn aci_rgb(i: u8) -> [u8; 3] {
|
||||
match i {
|
||||
1..=9 => STANDARD[usize::from(i - 1)],
|
||||
10..=249 => {
|
||||
let hue = f64::from(i / 10 - 1) * 15.0;
|
||||
let k = i % 10;
|
||||
let v = SHADES[usize::from(k / 2)];
|
||||
let pastel = k % 2 == 1;
|
||||
hue_rgb(hue).map(|f| {
|
||||
let f = if pastel { f + (1.0 - f) / 2.0 } else { f };
|
||||
(255.0 * v * f).floor() as u8
|
||||
})
|
||||
}
|
||||
250..=255 => {
|
||||
let g = (51.0 + 204.0 * f64::from(i - 250) / 5.0).floor() as u8;
|
||||
[g; 3]
|
||||
}
|
||||
_ => STANDARD[6],
|
||||
}
|
||||
}
|
||||
|
||||
/// The fully saturated, full-brightness colour of `hue` degrees as 0–1 channels.
|
||||
fn hue_rgb(hue: f64) -> [f64; 3] {
|
||||
let h = hue.rem_euclid(360.0) / 60.0;
|
||||
let x = 1.0 - (h % 2.0 - 1.0).abs();
|
||||
match h as u8 {
|
||||
0 => [1.0, x, 0.0],
|
||||
1 => [x, 1.0, 0.0],
|
||||
2 => [0.0, 1.0, x],
|
||||
3 => [0.0, x, 1.0],
|
||||
4 => [x, 0.0, 1.0],
|
||||
_ => [1.0, 0.0, x],
|
||||
}
|
||||
craft_dxf::model::aci::rgb(i)
|
||||
}
|
||||
|
||||
/// The indices a colour depth may use (true colour: all of them, for the fallback index).
|
||||
@@ -59,37 +20,13 @@ fn candidates(depth: ColorDepth) -> Box<dyn Iterator<Item = u8>> {
|
||||
/// The index of `depth` nearest to `rgb` (squared RGB distance; ties go to the lower index).
|
||||
/// Index 7 stands for both white and black, as CAD apps draw it in the background's contrast.
|
||||
pub fn nearest_aci(rgb: [u8; 3], depth: ColorDepth) -> u8 {
|
||||
let dist = |c: [u8; 3]| rgb.iter().zip(c).map(|(a, b)| (i32::from(*a) - i32::from(b)).pow(2)).sum::<i32>();
|
||||
candidates(depth).min_by_key(|i| if *i == 7 { dist([255; 3]).min(dist([0; 3])) } else { dist(aci_rgb(*i)) }).unwrap_or(7)
|
||||
craft_dxf::model::aci::nearest(rgb, candidates(depth))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_palette_matches_its_known_entries() {
|
||||
let known: [(u8, [u8; 3]); 14] = [
|
||||
(1, [255, 0, 0]),
|
||||
(10, [255, 0, 0]),
|
||||
(11, [255, 127, 127]),
|
||||
(12, [165, 0, 0]),
|
||||
(13, [165, 82, 82]),
|
||||
(17, [76, 38, 38]),
|
||||
(19, [38, 19, 19]),
|
||||
(20, [255, 63, 0]),
|
||||
(21, [255, 159, 127]),
|
||||
(30, [255, 127, 0]),
|
||||
(140, [0, 191, 255]),
|
||||
(250, [51, 51, 51]),
|
||||
(252, [132, 132, 132]),
|
||||
(255, [255, 255, 255]),
|
||||
];
|
||||
for (i, rgb) in known {
|
||||
assert_eq!(aci_rgb(i), rgb, "index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_index_by_depth() {
|
||||
assert_eq!(nearest_aci([255, 0, 0], ColorDepth::Aci8), 1);
|
||||
|
||||
+20
-19
@@ -1,4 +1,4 @@
|
||||
//! DXF import: an ASCII DXF drawing of any version as a document.
|
||||
//! DXF import: a DXF drawing (ASCII or binary) of any version as a document.
|
||||
//!
|
||||
//! The drawing's model space (or a paper layout) comes in at a ratio (1 unit of the art = N
|
||||
//! drawing units; by default the drawing's own unit at 1:1) or fitted to an artboard, centred
|
||||
@@ -10,7 +10,8 @@
|
||||
//! lineweights, linetypes and transparency come through layers and blocks as CAD apps resolve
|
||||
//! them. What can't come in is listed in the warnings.
|
||||
//!
|
||||
//! - `reader`: the file's text, group codes, sections, tables, blocks and layouts.
|
||||
//! - `reader`: what the import uses of the drawing `craft_dxf::model` reads: header, tables,
|
||||
//! blocks and layouts.
|
||||
//! - `entity`: entities to items (geometry and properties).
|
||||
//! - `curve`: bulges, arcs, ellipses and NURBS as Bézier paths.
|
||||
//! - `text`: text values to plain text.
|
||||
@@ -31,9 +32,6 @@ use crate::{DxfVersion, INSUNITS, SCALE_RANGE};
|
||||
/// Letter, the box Fit to Artboard fills unless told otherwise (points).
|
||||
pub const FIT_TO: (f64, f64) = (612.0, 792.0);
|
||||
|
||||
/// Why a binary DXF can't be opened.
|
||||
pub const BINARY: &str = "this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that";
|
||||
|
||||
/// How a drawing comes in.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ImportOptions {
|
||||
@@ -104,15 +102,20 @@ pub fn is_dxf(bytes: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The pairs of a DXF file and where it is damaged (binary and DWG files are refused).
|
||||
fn text(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, String> {
|
||||
if bytes.starts_with(reader::BINARY_SENTINEL) {
|
||||
return Err(BINARY.into());
|
||||
}
|
||||
/// The tags of a DXF file and where it is damaged (DWG files are refused).
|
||||
fn tags(bytes: &[u8]) -> Result<(Vec<craft_dxf::Tag>, Option<String>), String> {
|
||||
if bytes.starts_with(b"AC10") || bytes.starts_with(b"AC1.") {
|
||||
return Err("this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that".into());
|
||||
return Err("this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that".into());
|
||||
}
|
||||
Ok(reader::decode(bytes))
|
||||
let (tags, damaged) = craft_dxf::parse_recovering(bytes).map_err(|e| match e {
|
||||
craft_dxf::DxfError::TooLarge { .. } => "the drawing is too large to open".to_string(),
|
||||
_ if bytes.starts_with(reader::BINARY_SENTINEL) => "this binary DXF file is damaged or cut short".to_string(),
|
||||
_ => "not a DXF drawing".to_string(),
|
||||
})?;
|
||||
if tags.is_empty() {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
Ok((tags, damaged.map(|line| format!("the file is damaged at line {line}: what follows is left out"))))
|
||||
}
|
||||
|
||||
/// The unit of `$INSUNITS` code `code` and the scale that shows the drawing 1:1 in it.
|
||||
@@ -161,9 +164,8 @@ fn units_name(code: i64) -> &'static str {
|
||||
|
||||
/// Read what `bytes` holds: its version, units, layouts and layers.
|
||||
pub fn info(bytes: &[u8]) -> Result<DxfInfo, String> {
|
||||
let text = text(bytes)?;
|
||||
let (pairs, _) = reader::pairs(&text)?;
|
||||
let d = reader::Drawing::read(&pairs);
|
||||
let (tags, _) = tags(bytes)?;
|
||||
let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags));
|
||||
if d.sections == 0 {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
@@ -180,7 +182,7 @@ pub fn info(bytes: &[u8]) -> Result<DxfInfo, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Import an ASCII DXF drawing with `o`.
|
||||
/// Import a DXF drawing with `o`.
|
||||
pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
|
||||
let (w, h) = o.fit_to;
|
||||
if !(w > 0.0 && h > 0.0 && w.max(h) <= build::MAX_EXTENT) {
|
||||
@@ -191,9 +193,8 @@ pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
|
||||
{
|
||||
return Err(format!("scale must be a number from {} to {}, not {s}", SCALE_RANGE.start(), SCALE_RANGE.end()));
|
||||
}
|
||||
let text = text(bytes)?;
|
||||
let (pairs, damaged) = reader::pairs(&text)?;
|
||||
let d = reader::Drawing::read(&pairs);
|
||||
let (tags, damaged) = tags(bytes)?;
|
||||
let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags));
|
||||
if d.sections == 0 {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
|
||||
+180
-302
@@ -7,9 +7,10 @@ use kurbo::{BezPath, PathEl};
|
||||
use vectorcraft_doc::Justify;
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use craft_dxf::model::{self, EntityRecord, Groups, HatchEdge, HatchPath};
|
||||
|
||||
use super::curve::{self, Vertex};
|
||||
use super::reader::{Groups, Pair, Record, groups, number};
|
||||
use crate::aci::aci_rgb;
|
||||
use super::reader::{aci_paper, point};
|
||||
|
||||
/// How far below the baseline the lowest descender reaches, as a fraction of the cap height.
|
||||
const DESCENT: f64 = 0.3;
|
||||
@@ -25,21 +26,6 @@ const MAX_COORD: f64 = 1e12;
|
||||
/// factor 1, the 1 to 10,000% horizontal scale the Character panel allows.
|
||||
const SPAN_SCALE: (f64, f64) = (0.01, 100.0);
|
||||
|
||||
/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black.
|
||||
pub(crate) fn aci_paper(i: u64) -> [u8; 3] {
|
||||
match u8::try_from(i) {
|
||||
Ok(7) | Err(_) => [0; 3],
|
||||
Ok(i) => aci_rgb(i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The true colour (group 420, `0xRRGGBB`) of an entity or layer.
|
||||
pub(crate) fn true_color(g: &[Pair<'_>]) -> Option<[u8; 3]> {
|
||||
let v = g.int(420)?;
|
||||
let [_, r, gr, b] = u32::try_from(v & 0xFF_FFFF).ok()?.to_be_bytes();
|
||||
Some([r, gr, b])
|
||||
}
|
||||
|
||||
/// An entity's colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Col {
|
||||
@@ -79,30 +65,30 @@ pub(crate) struct Props {
|
||||
}
|
||||
|
||||
impl Props {
|
||||
fn read(g: &[Pair<'_>]) -> Self {
|
||||
let color = match (true_color(g), g.int(62)) {
|
||||
(Some(rgb), _) => Col::Rgb(rgb),
|
||||
(None, Some(0)) => Col::ByBlock,
|
||||
(None, None | Some(256..)) => Col::ByLayer,
|
||||
(None, Some(i)) => Col::Rgb(aci_paper(i.unsigned_abs())),
|
||||
fn read(c: &model::Common) -> Self {
|
||||
let color = match c.color {
|
||||
model::Color::Rgb(rgb) => Col::Rgb(rgb),
|
||||
model::Color::ByBlock => Col::ByBlock,
|
||||
model::Color::ByLayer => Col::ByLayer,
|
||||
model::Color::Index(i) => Col::Rgb(aci_paper(i)),
|
||||
};
|
||||
let lineweight = match g.int(370) {
|
||||
Some(-2) => Lw::ByBlock,
|
||||
Some(-3) => Lw::Default,
|
||||
Some(v @ 0..=211) => Lw::Hundredths(v as i32),
|
||||
let lineweight = match c.lineweight {
|
||||
model::Lineweight::ByBlock => Lw::ByBlock,
|
||||
model::Lineweight::Default => Lw::Default,
|
||||
model::Lineweight::Hundredths(v @ 0..=211) => Lw::Hundredths(i32::from(v)),
|
||||
_ => Lw::ByLayer,
|
||||
};
|
||||
let alpha = match g.int(440) {
|
||||
Some(v) if v & 0x0200_0000 != 0 => Alpha::Opacity((v & 0xFF) as f32 / 255.0),
|
||||
Some(v) if v & 0x0100_0000 != 0 => Alpha::ByBlock,
|
||||
_ => Alpha::ByLayer,
|
||||
let alpha = match c.transparency {
|
||||
model::Transparency::Alpha(a) => Alpha::Opacity(f32::from(a) / 255.0),
|
||||
model::Transparency::ByBlock => Alpha::ByBlock,
|
||||
model::Transparency::ByLayer => Alpha::ByLayer,
|
||||
};
|
||||
Self {
|
||||
layer: g.name(8).unwrap_or("0").to_string(),
|
||||
layer: c.layer.clone(),
|
||||
color,
|
||||
lineweight,
|
||||
linetype: g.name(6).unwrap_or("BYLAYER").to_uppercase(),
|
||||
ltscale: g.num(48).filter(|v| *v > 0.0).unwrap_or(1.0),
|
||||
linetype: c.linetype.as_deref().unwrap_or("BYLAYER").to_uppercase(),
|
||||
ltscale: c.ltscale,
|
||||
alpha,
|
||||
}
|
||||
}
|
||||
@@ -209,90 +195,87 @@ impl Converter {
|
||||
}
|
||||
|
||||
/// The items of `records` (an entity section or a block), in drawing order.
|
||||
pub fn items(&mut self, records: &[Record<'_>]) -> Vec<Item> {
|
||||
pub fn items(&mut self, records: &[EntityRecord<'_>]) -> Vec<Item> {
|
||||
let mut out = Vec::new();
|
||||
for group in groups(records) {
|
||||
let Some(head) = group.first() else { continue };
|
||||
for e in records {
|
||||
let m = model::Entity::read(e);
|
||||
// Invisible entities.
|
||||
if head.g.int(60) == Some(1) {
|
||||
if !m.common.visible {
|
||||
continue;
|
||||
}
|
||||
match self.geom(head, group) {
|
||||
match self.geom(e, &m) {
|
||||
Some(geom) if !in_range(&geom) => self.warn(FAR_AWAY),
|
||||
Some(geom) => out.push(Item { props: Props::read(head.g), geom }),
|
||||
None if is_known(head.kind) => {}
|
||||
None => self.skip(head.kind),
|
||||
Some(geom) => out.push(Item { props: Props::read(&m.common), geom }),
|
||||
None if is_known(e.kind) => {}
|
||||
None => self.skip(e.kind),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn geom(&mut self, head: &Record<'_>, group: &[Record<'_>]) -> Option<Geom> {
|
||||
let g = head.g;
|
||||
fn geom(&mut self, e: &EntityRecord<'_>, m: &model::Entity) -> Option<Geom> {
|
||||
let g = e.groups();
|
||||
let path = |bp: BezPath| Some(Geom::Path { bp, fill: false, width: 0.0 });
|
||||
match head.kind {
|
||||
"LINE" => {
|
||||
let ocs = ocs(m.common.extrusion);
|
||||
match &m.kind {
|
||||
model::EntityKind::Line { start, end } => {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(g.point(10));
|
||||
bp.line_to(g.point(11));
|
||||
bp.move_to(point(*start));
|
||||
bp.line_to(point(*end));
|
||||
path(bp)
|
||||
}
|
||||
"CIRCLE" => path(transformed(curve::arc(g.point(10), g.num(40)?, 0.0, std::f64::consts::TAU)?, ocs(g))),
|
||||
"ARC" => {
|
||||
let (start, end) = (g.num_or(50, 0.0), g.num_or(51, 360.0));
|
||||
let sweep = ccw_sweep(start, end);
|
||||
path(transformed(curve::arc(g.point(10), g.num(40)?, start.to_radians(), sweep.to_radians())?, ocs(g)))
|
||||
model::EntityKind::Circle { center, radius } => path(transformed(curve::arc(point(*center), *radius, 0.0, std::f64::consts::TAU)?, ocs)),
|
||||
model::EntityKind::Arc { center, radius, start_angle, end_angle } => {
|
||||
let sweep = ccw_sweep(*start_angle, *end_angle);
|
||||
path(transformed(curve::arc(point(*center), *radius, start_angle.to_radians(), sweep.to_radians())?, ocs))
|
||||
}
|
||||
"ELLIPSE" => path(ellipse(g)?),
|
||||
"LWPOLYLINE" => lwpolyline(g),
|
||||
"POLYLINE" => self.polyline(g, group.get(1..).unwrap_or(&[])),
|
||||
"SPLINE" => path(spline(g)?),
|
||||
"SOLID" | "TRACE" => {
|
||||
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
|
||||
model::EntityKind::Ellipse { center, major, ratio, start, end } => {
|
||||
path(ellipse(m.common.extrusion, *center, *major, *ratio, *start, *end)?)
|
||||
}
|
||||
model::EntityKind::LwPolyline(p) => lwpolyline(p, ocs),
|
||||
model::EntityKind::Polyline(p) => self.polyline(p, ocs),
|
||||
model::EntityKind::Spline(s) => path(spline(s)?),
|
||||
model::EntityKind::Solid { corners } | model::EntityKind::Trace { corners } => {
|
||||
let [a, b, c, d] = corners.map(point);
|
||||
// The corners zigzag: the outline runs 1, 2, 4, 3.
|
||||
Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs(g)), fill: true, width: 0.0 })
|
||||
Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs), fill: true, width: 0.0 })
|
||||
}
|
||||
"3DFACE" => {
|
||||
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
|
||||
path(polygon(&[a, b, c, d]))
|
||||
}
|
||||
"HATCH" => self.hatch(g),
|
||||
"TEXT" | "ATTRIB" => text(g, head.kind == "ATTRIB").map(Geom::Text),
|
||||
"MTEXT" => mtext(g).map(Geom::Text),
|
||||
"INSERT" => self.insert(g, group.get(1..).unwrap_or(&[])),
|
||||
model::EntityKind::Face3d { corners, .. } => path(polygon(&corners.map(point))),
|
||||
model::EntityKind::Hatch(h) => self.hatch(h, ocs),
|
||||
model::EntityKind::Text(t) => text(t, &g, false, ocs).map(Geom::Text),
|
||||
model::EntityKind::MText(t) => mtext(t, e.tags).map(Geom::Text),
|
||||
model::EntityKind::Insert(ins) => self.insert(ins, &e.followers, ocs),
|
||||
// A dimension draws the anonymous block of its lines, arrows and text.
|
||||
"DIMENSION" => Some(Geom::Insert(InsertItem { block: g.name(2)?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })),
|
||||
model::EntityKind::Dimension(d) => {
|
||||
Some(Geom::Insert(InsertItem { block: d.block.as_ref()?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn polyline(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
|
||||
let flags = g.int_or(70, 0);
|
||||
if flags & (16 | 64) != 0 {
|
||||
fn polyline(&mut self, p: &model::Polyline, ocs: Affine) -> Option<Geom> {
|
||||
if p.is_mesh() || p.is_polyface() {
|
||||
self.warn(MESHES);
|
||||
return None;
|
||||
}
|
||||
// Spline frame control points and polyface faces aren't on the line.
|
||||
let verts: Vec<Vertex> = rest
|
||||
.iter()
|
||||
.filter(|r| r.kind == "VERTEX" && r.g.int_or(70, 0) & (16 | 128) == 0)
|
||||
.map(|r| Vertex { p: r.g.point(10), bulge: r.g.num_or(42, 0.0) })
|
||||
.collect();
|
||||
let bp = curve::polyline(&verts, flags & 1 != 0)?;
|
||||
let verts: Vec<Vertex> =
|
||||
p.vertices.iter().filter(|v| v.flags & (16 | 128) == 0).map(|v| Vertex { p: point(v.location), bulge: v.bulge }).collect();
|
||||
let bp = curve::polyline(&verts, p.closed())?;
|
||||
// 3D polylines are in world coordinates; 2D ones in their plane's.
|
||||
let bp = if flags & 8 != 0 { bp } else { transformed(bp, ocs(g)) };
|
||||
Some(Geom::Path { bp, fill: false, width: g.num_or(40, 0.0).max(0.0) })
|
||||
let bp = if p.is_3d() { bp } else { transformed(bp, ocs) };
|
||||
Some(Geom::Path { bp, fill: false, width: p.start_width.max(0.0) })
|
||||
}
|
||||
|
||||
fn insert(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
|
||||
let block = g.name(2)?.to_uppercase();
|
||||
let (sx, sy) = (g.num_or(41, 1.0), g.num_or(42, 1.0));
|
||||
fn insert(&mut self, ins: &model::Insert, followers: &[model::Record<'_>], ocs: Affine) -> Option<Geom> {
|
||||
let block = Some(ins.block.trim()).filter(|b| !b.is_empty())?.to_uppercase();
|
||||
let [sx, sy, _] = ins.scale;
|
||||
if sx.abs() < 1e-12 || sy.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let place = ocs(g) * Affine::translate(g.point(10).to_vec2()) * Affine::rotate(g.num_or(50, 0.0).to_radians());
|
||||
let (cols, rows) = (g.int_or(70, 1).max(1), g.int_or(71, 1).max(1));
|
||||
let (cs, rs) = (g.num_or(44, 0.0), g.num_or(45, 0.0));
|
||||
let place = ocs * Affine::translate(point(ins.insert).to_vec2()) * Affine::rotate(ins.rotation.to_radians());
|
||||
let (cols, rows) = (ins.columns, ins.rows);
|
||||
let (cs, rs) = (ins.column_spacing, ins.row_spacing);
|
||||
let (cols, rows) = if cols.saturating_mul(rows) > MAX_CELLS {
|
||||
self.warn(BIG_ARRAY);
|
||||
(cols.min(MAX_CELLS), (MAX_CELLS / cols.min(MAX_CELLS)).max(1).min(rows))
|
||||
@@ -312,27 +295,41 @@ impl Converter {
|
||||
.flat_map(|r| (0..cols).map(move |c| (r, c)))
|
||||
.map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy))
|
||||
.collect();
|
||||
let attribs = self.items(rest);
|
||||
let attribs = self.attribs(followers);
|
||||
Some(Geom::Insert(InsertItem { block, cells, attribs }))
|
||||
}
|
||||
|
||||
fn hatch(&mut self, g: &[Pair<'_>]) -> Option<Geom> {
|
||||
let start = g.iter().position(|p| p.code == 91)?;
|
||||
let count = g.get(start).and_then(|p| number(p.value)).filter(|n| *n >= 0.0)? as usize;
|
||||
let mut c = Cursor { g, i: start + 1 };
|
||||
/// An insert's attributes as text.
|
||||
fn attribs(&mut self, followers: &[model::Record<'_>]) -> Vec<Item> {
|
||||
let mut out = Vec::new();
|
||||
for r in followers {
|
||||
let c = model::Common::read(r.tags);
|
||||
if !c.visible {
|
||||
continue;
|
||||
}
|
||||
let a = model::Attrib::read("ATTRIB", r.tags);
|
||||
match text(&a.text, &r.groups(), true, ocs(c.extrusion)) {
|
||||
Some(t) if !in_range(&Geom::Text(t.clone())) => self.warn(FAR_AWAY),
|
||||
Some(t) => out.push(Item { props: Props::read(&c), geom: Geom::Text(t) }),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn hatch(&mut self, h: &model::Hatch, ocs: Affine) -> Option<Geom> {
|
||||
let mut bp = BezPath::new();
|
||||
for _ in 0..count {
|
||||
let Some(path) = boundary(&mut c) else { break };
|
||||
for l in &h.loops {
|
||||
let Some(path) = boundary(&l.path) else { break };
|
||||
bp.extend(path);
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bp = transformed(bp, ocs(g));
|
||||
let solid = g.int_or(70, 0) == 1;
|
||||
if g.int_or(450, 0) == 1 {
|
||||
let bp = transformed(bp, ocs);
|
||||
if h.gradient.is_some() {
|
||||
self.warn(GRADIENT_HATCH);
|
||||
} else if !solid {
|
||||
} else if !h.solid {
|
||||
self.warn(PATTERN_HATCH);
|
||||
return Some(Geom::Path { bp, fill: false, width: 0.0 });
|
||||
}
|
||||
@@ -401,217 +398,104 @@ fn polygon(pts: &[Point]) -> BezPath {
|
||||
bp
|
||||
}
|
||||
|
||||
/// The extrusion direction (210, 220, 230) of a planar entity, normalised.
|
||||
fn extrusion(g: &[Pair<'_>]) -> [f64; 3] {
|
||||
let n = [g.num_or(210, 0.0), g.num_or(220, 0.0), g.num_or(230, 1.0)];
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||
if len > 1e-12 && len.is_finite() { n.map(|v| v / len) } else { [0.0, 0.0, 1.0] }
|
||||
}
|
||||
|
||||
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
fn unit(v: [f64; 3]) -> [f64; 3] {
|
||||
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
if len > 1e-12 { v.map(|x| x / len) } else { v }
|
||||
}
|
||||
|
||||
/// A planar entity's coordinate system seen from above: its x and y axes in world space by the
|
||||
/// arbitrary axis algorithm (identity for drawings in the world's plane; extruded along −z it
|
||||
/// mirrors x).
|
||||
fn ocs(g: &[Pair<'_>]) -> Affine {
|
||||
let n = extrusion(g);
|
||||
if n[2] > 1.0 - 1e-12 {
|
||||
fn ocs(extrusion: model::Point) -> Affine {
|
||||
let [ax, ay, _] = model::ocs_axes(extrusion);
|
||||
if ax == [1.0, 0.0, 0.0] && ay == [0.0, 1.0, 0.0] {
|
||||
return Affine::IDENTITY;
|
||||
}
|
||||
let ax = unit(if n[0].abs() < 1.0 / 64.0 && n[1].abs() < 1.0 / 64.0 { cross([0.0, 1.0, 0.0], n) } else { cross([0.0, 0.0, 1.0], n) });
|
||||
let ay = unit(cross(n, ax));
|
||||
Affine::new([ax[0], ax[1], ay[0], ay[1], 0.0, 0.0])
|
||||
}
|
||||
|
||||
fn ellipse(g: &[Pair<'_>]) -> Option<BezPath> {
|
||||
let centre = g.point(10);
|
||||
let major = g.point(11).to_vec2();
|
||||
let ratio = g.num_or(40, 1.0);
|
||||
fn ellipse(extrusion: model::Point, centre: model::Point, major: model::Point, ratio: f64, start: f64, end: f64) -> Option<BezPath> {
|
||||
let centre = point(centre);
|
||||
let major = point(major).to_vec2();
|
||||
if ratio <= 0.0 || major.hypot() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
// The minor axis is the major one turned a quarter about the extrusion direction.
|
||||
let n = extrusion(g);
|
||||
let n = model::ocs_axes(extrusion)[2];
|
||||
let m = cross(n, [major.x, major.y, 0.0]);
|
||||
let minor = Vec2::new(m[0], m[1]) * ratio;
|
||||
let (start, end) = (g.num_or(41, 0.0), g.num_or(42, std::f64::consts::TAU));
|
||||
let sweep = ccw_sweep(start.to_degrees(), end.to_degrees()).to_radians();
|
||||
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start, sweep)
|
||||
}
|
||||
|
||||
fn lwpolyline(g: &[Pair<'_>]) -> Option<Geom> {
|
||||
let mut verts: Vec<Vertex> = Vec::new();
|
||||
let (mut widths, mut uniform) = (None, true);
|
||||
for p in g {
|
||||
match p.code {
|
||||
10 => verts.push(Vertex { p: Point::new(number(p.value)?, 0.0), bulge: 0.0 }),
|
||||
20 => verts.last_mut()?.p.y = number(p.value)?,
|
||||
42 => verts.last_mut()?.bulge = number(p.value).unwrap_or(0.0),
|
||||
40 | 41 => {
|
||||
let w = number(p.value).unwrap_or(0.0);
|
||||
uniform &= widths.is_none_or(|x: f64| (x - w).abs() < 1e-12);
|
||||
widths = Some(w);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let bp = transformed(curve::polyline(&verts, g.int_or(70, 0) & 1 != 0)?, ocs(g));
|
||||
let width = g.num(43).filter(|w| *w > 0.0).or(widths.filter(|_| uniform)).unwrap_or(0.0).max(0.0);
|
||||
fn lwpolyline(p: &model::LwPolyline, ocs: Affine) -> Option<Geom> {
|
||||
let verts: Vec<Vertex> = p.vertices.iter().map(|v| Vertex { p: Point::new(v.x, v.y), bulge: v.bulge }).collect();
|
||||
let bp = transformed(curve::polyline(&verts, p.closed)?, ocs);
|
||||
// One width for every vertex is the line's width.
|
||||
let widths: Vec<f64> = p.vertices.iter().flat_map(|v| [v.start_width, v.end_width]).collect();
|
||||
let uniform = widths.first().filter(|w| widths.iter().all(|x| (x - *w).abs() < 1e-12)).copied();
|
||||
let width = Some(p.const_width).filter(|w| *w > 0.0).or(uniform).unwrap_or(0.0).max(0.0);
|
||||
Some(Geom::Path { bp, fill: false, width })
|
||||
}
|
||||
|
||||
/// The points at `code` (x) and `code + 10` (y), in order.
|
||||
fn points(g: &[Pair<'_>], code: i32) -> Option<Vec<Point>> {
|
||||
let mut out: Vec<Point> = Vec::new();
|
||||
for p in g {
|
||||
if p.code == code {
|
||||
out.push(Point::new(number(p.value)?, 0.0));
|
||||
} else if p.code == code + 10 {
|
||||
out.last_mut()?.y = number(p.value)?;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn spline(g: &[Pair<'_>]) -> Option<BezPath> {
|
||||
let flags = g.int_or(70, 0);
|
||||
let ctrl = points(g, 10)?;
|
||||
let degree = usize::try_from(g.int_or(71, 3)).ok()?;
|
||||
let knots: Vec<f64> = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::<Option<_>>()?;
|
||||
let weights: Vec<f64> = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect();
|
||||
let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice());
|
||||
curve::nurbs(degree, &knots, &ctrl, weights)
|
||||
fn spline(s: &model::Spline) -> Option<BezPath> {
|
||||
let ctrl: Vec<Point> = s.control.iter().map(|p| point(*p)).collect();
|
||||
let degree = usize::try_from(s.degree).ok()?;
|
||||
let weights = (s.flags & 4 != 0 && s.weights.len() == ctrl.len()).then_some(s.weights.as_slice());
|
||||
curve::nurbs(degree, &s.knots, &ctrl, weights)
|
||||
.map(|mut path| {
|
||||
if flags & 1 != 0 {
|
||||
if s.flags & 1 != 0 {
|
||||
path.close_path();
|
||||
}
|
||||
path
|
||||
})
|
||||
.or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
|
||||
}
|
||||
|
||||
/// Reads a hatch's boundary groups in order.
|
||||
struct Cursor<'g, 'a> {
|
||||
g: &'g [Pair<'a>],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl Cursor<'_, '_> {
|
||||
/// The next group's value when it has `code` (consumed).
|
||||
fn opt(&mut self, code: i32) -> Option<&str> {
|
||||
let p = self.g.get(self.i).filter(|p| p.code == code)?;
|
||||
self.i += 1;
|
||||
Some(p.value)
|
||||
}
|
||||
fn num(&mut self, code: i32) -> Option<f64> {
|
||||
self.opt(code).and_then(number)
|
||||
}
|
||||
fn count(&mut self, code: i32) -> Option<usize> {
|
||||
self.num(code).filter(|n| *n >= 0.0 && *n <= 1e7).map(|n| n as usize)
|
||||
}
|
||||
fn point(&mut self, code: i32) -> Option<Point> {
|
||||
Some(Point::new(self.num(code)?, self.num(code + 10)?))
|
||||
}
|
||||
.or_else(|| curve::through(&s.fit.iter().map(|p| point(*p)).collect::<Vec<_>>(), s.flags & 1 != 0))
|
||||
}
|
||||
|
||||
/// One hatch boundary loop, closed.
|
||||
fn boundary(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
|
||||
let flags = c.num(92)? as i64;
|
||||
let mut bp = BezPath::new();
|
||||
if flags & 2 != 0 {
|
||||
let bulges = c.num(72).unwrap_or(0.0) != 0.0;
|
||||
let _closed = c.opt(73);
|
||||
let n = c.count(93)?;
|
||||
let mut verts = Vec::with_capacity(n.min(4096));
|
||||
for _ in 0..n {
|
||||
let p = c.point(10)?;
|
||||
let bulge = if bulges { c.num(42).unwrap_or(0.0) } else { 0.0 };
|
||||
verts.push(Vertex { p, bulge });
|
||||
fn boundary(path: &HatchPath) -> Option<BezPath> {
|
||||
let xy = |p: &[f64; 2]| Point::new(p[0], p[1]);
|
||||
match path {
|
||||
HatchPath::Polyline { vertices, .. } => {
|
||||
let verts: Vec<Vertex> = vertices.iter().map(|[x, y, b]| Vertex { p: Point::new(*x, *y), bulge: *b }).collect();
|
||||
curve::polyline(&verts, true)
|
||||
}
|
||||
bp = curve::polyline(&verts, true)?;
|
||||
} else {
|
||||
let n = c.count(93)?;
|
||||
for _ in 0..n {
|
||||
let edge = edge(c)?;
|
||||
append(&mut bp, &edge);
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
bp.close_path();
|
||||
}
|
||||
// The boundary objects it was made from.
|
||||
if let Some(n) = c.count(97) {
|
||||
for _ in 0..n {
|
||||
if c.opt(330).is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// One edge of a hatch boundary.
|
||||
fn edge(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
|
||||
match c.num(72)? as i64 {
|
||||
1 => {
|
||||
HatchPath::Edges(edges) => {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(c.point(10)?);
|
||||
bp.line_to(c.point(11)?);
|
||||
for e in edges {
|
||||
let seg = match e {
|
||||
HatchEdge::Line { start, end } => {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(xy(start));
|
||||
bp.line_to(xy(end));
|
||||
Some(bp)
|
||||
}
|
||||
HatchEdge::Arc { center, radius, start_angle, end_angle, ccw } => {
|
||||
let sweep = ccw_sweep(*start_angle, *end_angle);
|
||||
// Clockwise arcs have their angles measured clockwise.
|
||||
let (start, sweep) = if *ccw { (*start_angle, sweep) } else { (-start_angle, -sweep) };
|
||||
curve::arc(xy(center), *radius, start.to_radians(), sweep.to_radians())
|
||||
}
|
||||
HatchEdge::Ellipse { center, major, ratio, start_angle, end_angle, ccw } => {
|
||||
let (centre, major) = (xy(center), xy(major).to_vec2());
|
||||
let minor = Vec2::new(-major.y, major.x) * *ratio * if *ccw { 1.0 } else { -1.0 };
|
||||
let sweep = ccw_sweep(*start_angle, *end_angle).to_radians();
|
||||
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start_angle.to_radians(), sweep)
|
||||
}
|
||||
HatchEdge::Spline { degree, rational, knots, control, weights, fit, .. } => {
|
||||
let ctrl: Vec<Point> = control.iter().map(xy).collect();
|
||||
let degree = usize::try_from(*degree).ok()?;
|
||||
let weights = (*rational && weights.len() == ctrl.len()).then_some(weights.as_slice());
|
||||
curve::nurbs(degree, knots, &ctrl, weights).or_else(|| curve::through(&fit.iter().map(xy).collect::<Vec<_>>(), false))
|
||||
}
|
||||
};
|
||||
append(&mut bp, &seg?);
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
bp.close_path();
|
||||
Some(bp)
|
||||
}
|
||||
2 => {
|
||||
let (centre, r, start, end) = (c.point(10)?, c.num(40)?, c.num(50)?, c.num(51)?);
|
||||
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
|
||||
let sweep = ccw_sweep(start, end);
|
||||
// Clockwise arcs have their angles measured clockwise.
|
||||
let (start, sweep) = if ccw { (start, sweep) } else { (-start, -sweep) };
|
||||
curve::arc(centre, r, start.to_radians(), sweep.to_radians())
|
||||
}
|
||||
3 => {
|
||||
let (centre, major, ratio, start, end) = (c.point(10)?, c.point(11)?.to_vec2(), c.num(40)?, c.num(50)?, c.num(51)?);
|
||||
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
|
||||
let minor = Vec2::new(-major.y, major.x) * ratio * if ccw { 1.0 } else { -1.0 };
|
||||
let sweep = ccw_sweep(start, end).to_radians();
|
||||
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start.to_radians(), sweep)
|
||||
}
|
||||
4 => {
|
||||
let degree = c.count(94)?;
|
||||
let rational = c.num(73).unwrap_or(0.0) != 0.0;
|
||||
let _periodic = c.opt(74);
|
||||
let (nk, nc) = (c.count(95)?, c.count(96)?);
|
||||
let knots: Vec<f64> = (0..nk).map(|_| c.num(40)).collect::<Option<_>>()?;
|
||||
let mut ctrl = Vec::with_capacity(nc.min(4096));
|
||||
let mut weights = Vec::with_capacity(nc.min(4096));
|
||||
for _ in 0..nc {
|
||||
ctrl.push(c.point(10)?);
|
||||
weights.push(c.num(42).unwrap_or(1.0));
|
||||
}
|
||||
// Fit data (DXF 2010 and later): a count, then that many points. Older files have none,
|
||||
// and the 97 that may follow is the boundary's count of source objects (then their
|
||||
// handles), so it is only the fit count when it is 0 or points follow.
|
||||
let fit_count = match (c.g.get(c.i), c.g.get(c.i + 1)) {
|
||||
(Some(count), next) if count.code == 97 => number(count.value) == Some(0.0) || next.is_some_and(|p| p.code == 11),
|
||||
_ => false,
|
||||
};
|
||||
let fit: Vec<Point> = match fit_count.then(|| c.count(97)).flatten() {
|
||||
Some(n) => (0..n).map(|_| c.point(11)).collect::<Option<_>>()?,
|
||||
None => vec![],
|
||||
};
|
||||
// Start and end tangents.
|
||||
for code in [12, 13] {
|
||||
c.point(code);
|
||||
}
|
||||
curve::nurbs(degree, &knots, &ctrl, rational.then_some(weights.as_slice())).or_else(|| curve::through(&fit, false))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,18 +527,21 @@ fn baseline(v: i64, height: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
|
||||
if attrib && g.int_or(70, 0) & 1 != 0 {
|
||||
/// TEXT, or an ATTRIB's text. The string is read from group 1 as written, so that the caret and
|
||||
/// percent codes are turned into text once, by `super::text`.
|
||||
fn text(t: &model::Text, g: &Groups<'_>, attrib: bool, ocs: Affine) -> Option<TextItem> {
|
||||
if attrib && g.i_or(70, 0) & 1 != 0 {
|
||||
return None;
|
||||
}
|
||||
let s = super::text::plain(g.raw(1)?);
|
||||
let s = super::text::plain(&g.s(1)?);
|
||||
if s.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let height = g.num(40).filter(|h| *h > 0.0)?;
|
||||
let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0));
|
||||
let (p1, p2) = (g.point(10), g.point_opt(11));
|
||||
let mut angle = g.num_or(50, 0.0).to_radians();
|
||||
let height = Some(t.height).filter(|h| *h > 0.0)?;
|
||||
// An ATTRIB's 73 is its field length: its vertical alignment is 74.
|
||||
let (h, v) = (t.halign, if attrib { g.i_or(74, 0) } else { t.valign });
|
||||
let (p1, p2) = (point(t.insert), t.align.map(point));
|
||||
let mut angle = t.rotation.to_radians();
|
||||
let mut span = None;
|
||||
// Aligned and fit text run from the first point to the second.
|
||||
let anchor = match (h, p2) {
|
||||
@@ -674,33 +561,36 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
|
||||
_ => Justify::Left,
|
||||
};
|
||||
let v = if h == 4 { 2 } else { v };
|
||||
let flags = g.int_or(71, 0);
|
||||
let flags = t.generation;
|
||||
let mirror = Affine::scale_non_uniform(if flags & 2 != 0 { -1.0 } else { 1.0 }, if flags & 4 != 0 { -1.0 } else { 1.0 });
|
||||
let xf = ocs(g) * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height)));
|
||||
let xf = ocs * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height)));
|
||||
Some(TextItem {
|
||||
text: s,
|
||||
height,
|
||||
xf,
|
||||
width: g.num(41).filter(|w| *w > 0.0).unwrap_or(1.0),
|
||||
width: Some(t.width_factor).filter(|w| *w > 0.0).unwrap_or(1.0),
|
||||
justify,
|
||||
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
|
||||
style: t.style.to_uppercase(),
|
||||
family: None,
|
||||
leading: None,
|
||||
span,
|
||||
})
|
||||
}
|
||||
|
||||
fn mtext(g: &[Pair<'_>]) -> Option<TextItem> {
|
||||
// The text comes in 250-character chunks (3) before the last (1).
|
||||
let raw: String = g.iter().filter(|p| p.code == 3).map(|p| p.value).chain(g.raw(1)).collect();
|
||||
/// MTEXT. The contents are read from groups 3 and 1 as written, as for [`text`].
|
||||
fn mtext(t: &model::MText, tags: &[craft_dxf::Tag]) -> Option<TextItem> {
|
||||
// The text comes in 250-character chunks (3) before the last (1), up to an embedded object.
|
||||
let (own, _) = model::text::split_embedded(tags);
|
||||
let g = Groups(own);
|
||||
let raw: String = own.iter().filter(|t| t.code == 3).map(craft_dxf::Tag::str).chain(g.s(1)).collect();
|
||||
let (s, family) = super::text::mtext(&raw);
|
||||
if s.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let height = g.num(40).filter(|h| *h > 0.0)?;
|
||||
let leading = height * LINE_SPACING * g.num(44).filter(|f| *f > 0.0).unwrap_or(1.0);
|
||||
let height = Some(t.height).filter(|h| *h > 0.0)?;
|
||||
let leading = height * LINE_SPACING * Some(t.line_spacing).filter(|f| *f > 0.0).unwrap_or(1.0);
|
||||
let lines = s.lines().count().max(1) as f64;
|
||||
let attach = g.int_or(71, 1).clamp(1, 9) - 1;
|
||||
let attach = i64::from(t.attach) - 1;
|
||||
let justify = [Justify::Left, Justify::Center, Justify::Right].get((attach % 3) as usize).copied().unwrap_or_default();
|
||||
// The first baseline below the attachment point: under the top, around the middle, above
|
||||
// the bottom of the lines.
|
||||
@@ -710,18 +600,6 @@ fn mtext(g: &[Pair<'_>]) -> Option<TextItem> {
|
||||
1 => total / 2.0 - height,
|
||||
_ => (lines - 1.0) * leading,
|
||||
};
|
||||
let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12);
|
||||
let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2());
|
||||
let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first));
|
||||
Some(TextItem {
|
||||
text: s,
|
||||
height,
|
||||
xf,
|
||||
width: 1.0,
|
||||
justify,
|
||||
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
|
||||
family,
|
||||
leading: Some(leading),
|
||||
span: None,
|
||||
})
|
||||
let xf = Affine::translate(point(t.insert).to_vec2()) * Affine::rotate(t.angle()) * Affine::translate((0.0, first));
|
||||
Some(TextItem { text: s, height, xf, width: 1.0, justify, style: t.style.to_uppercase(), family, leading: Some(leading), span: None })
|
||||
}
|
||||
|
||||
+68
-331
@@ -1,156 +1,14 @@
|
||||
//! Reading an ASCII DXF file: its text, the group-code pairs, and what the import uses of its
|
||||
//! sections (header variables; the layer, linetype, text style and block record tables; the
|
||||
//! blocks; the entities; the layouts).
|
||||
//! What the import uses of a drawing, from `craft_dxf::model`: header variables; the layer,
|
||||
//! linetype and text style tables; the blocks; the entities; the layouts.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use craft_dxf::model::{self, EntityRecord};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
/// The most group-code pairs a drawing may hold (about 300 MB of DXF).
|
||||
const MAX_PAIRS: usize = 20_000_000;
|
||||
|
||||
/// The first bytes of a binary DXF file (its format's signature).
|
||||
pub const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\x00";
|
||||
|
||||
/// One group: its code and its value as written.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Pair<'a> {
|
||||
pub code: i32,
|
||||
pub value: &'a str,
|
||||
}
|
||||
|
||||
/// Values of a run of pairs by group code.
|
||||
pub(crate) trait Groups<'a> {
|
||||
/// The value of the first `code`, as written.
|
||||
fn raw(&self, code: i32) -> Option<&'a str>;
|
||||
/// The first `code` as a name (trimmed, never empty).
|
||||
fn name(&self, code: i32) -> Option<&'a str> {
|
||||
self.raw(code).map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
/// The first `code` as a finite number.
|
||||
fn num(&self, code: i32) -> Option<f64> {
|
||||
self.raw(code).and_then(number)
|
||||
}
|
||||
fn num_or(&self, code: i32, default: f64) -> f64 {
|
||||
self.num(code).unwrap_or(default)
|
||||
}
|
||||
/// The first `code` as a whole number.
|
||||
fn int(&self, code: i32) -> Option<i64> {
|
||||
self.raw(code).and_then(integer)
|
||||
}
|
||||
fn int_or(&self, code: i32, default: i64) -> i64 {
|
||||
self.int(code).unwrap_or(default)
|
||||
}
|
||||
/// The point at `code` (x) and `code + 10` (y); 0 for what is missing.
|
||||
fn point(&self, code: i32) -> Point {
|
||||
Point::new(self.num_or(code, 0.0), self.num_or(code + 10, 0.0))
|
||||
}
|
||||
/// [`Groups::point`] when its x is given.
|
||||
fn point_opt(&self, code: i32) -> Option<Point> {
|
||||
self.num(code).map(|x| Point::new(x, self.num_or(code + 10, 0.0)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Groups<'a> for [Pair<'a>] {
|
||||
fn raw(&self, code: i32) -> Option<&'a str> {
|
||||
self.iter().find(|p| p.code == code).map(|p| p.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// A finite number (`1e999`, `NaN` and junk are none).
|
||||
pub(crate) fn number(s: &str) -> Option<f64> {
|
||||
s.trim().parse::<f64>().ok().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// A whole number; some writers put integers as `1.0`.
|
||||
pub(crate) fn integer(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
s.parse::<i64>().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15 && v.fract() == 0.0).map(|v| v as i64))
|
||||
}
|
||||
|
||||
/// One object: its type (the value of its `0` group) and the groups up to the next object.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Record<'a> {
|
||||
pub kind: &'a str,
|
||||
pub g: &'a [Pair<'a>],
|
||||
}
|
||||
|
||||
/// The text of a DXF file: UTF-8 (DXF 2007 and later), else the Windows code page older versions
|
||||
/// write (a byte order mark is dropped).
|
||||
pub(crate) fn decode(bytes: &[u8]) -> Cow<'_, str> {
|
||||
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => Cow::Borrowed(s),
|
||||
Err(_) => Cow::Owned(bytes.iter().map(|b| windows_1252(*b)).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte of the Windows Western code page.
|
||||
fn windows_1252(b: u8) -> char {
|
||||
const HIGH: [char; 32] = [
|
||||
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
|
||||
'˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
|
||||
];
|
||||
match b {
|
||||
0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'),
|
||||
_ => char::from(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// The group-code pairs of `text`, up to `0 EOF`. A line that isn't a group code ends the
|
||||
/// reading: what came before is kept and the second value says where the file is damaged.
|
||||
pub(crate) fn pairs(text: &str) -> Result<(Vec<Pair<'_>>, Option<String>), String> {
|
||||
let mut out = Vec::new();
|
||||
let mut lines = text.lines().enumerate();
|
||||
let mut damaged = None;
|
||||
while let Some((n, code)) = lines.next() {
|
||||
let c = code.trim();
|
||||
if c.is_empty() && out.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(code) = c.parse::<i32>() else {
|
||||
damaged = Some(format!("the file is damaged at line {}: what follows is left out", n + 1));
|
||||
break;
|
||||
};
|
||||
let value = lines.next().map_or("", |(_, v)| v);
|
||||
if code == 0 && value.trim() == "EOF" {
|
||||
break;
|
||||
}
|
||||
if out.len() >= MAX_PAIRS {
|
||||
return Err("the drawing is too large to open".into());
|
||||
}
|
||||
out.push(Pair { code, value });
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
Ok((out, damaged))
|
||||
}
|
||||
|
||||
/// The objects in `pairs`: each from a `0` group up to the next.
|
||||
fn records<'a>(pairs: &'a [Pair<'a>]) -> Vec<Record<'a>> {
|
||||
let mut out = Vec::new();
|
||||
let mut start: Option<usize> = None;
|
||||
for (i, p) in pairs.iter().enumerate() {
|
||||
if p.code == 0 {
|
||||
if let Some(s) = start {
|
||||
out.extend(record(pairs, s, i));
|
||||
}
|
||||
start = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
out.extend(record(pairs, s, pairs.len()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn record<'a>(pairs: &'a [Pair<'a>], start: usize, end: usize) -> Option<Record<'a>> {
|
||||
let kind = pairs.get(start)?.value.trim();
|
||||
Some(Record { kind, g: pairs.get(start + 1..end)? })
|
||||
}
|
||||
|
||||
/// The header variables the import reads.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Header {
|
||||
@@ -186,7 +44,7 @@ pub(crate) struct LayerDef {
|
||||
pub(crate) struct BlockDef<'a> {
|
||||
pub name: String,
|
||||
pub base: Point,
|
||||
pub records: Vec<Record<'a>>,
|
||||
pub records: Vec<EntityRecord<'a>>,
|
||||
/// A reference to another drawing (its art isn't in this file).
|
||||
pub xref: bool,
|
||||
}
|
||||
@@ -218,97 +76,51 @@ pub(crate) struct Drawing<'a> {
|
||||
/// Blocks by upper-case name.
|
||||
pub blocks: HashMap<String, BlockDef<'a>>,
|
||||
/// The ENTITIES section.
|
||||
pub entities: Vec<Record<'a>>,
|
||||
pub entities: Vec<EntityRecord<'a>>,
|
||||
/// Model space first, then the paper layouts in tab order.
|
||||
pub layouts: Vec<LayoutDef>,
|
||||
/// How many sections the file has (none: not a drawing).
|
||||
pub sections: usize,
|
||||
}
|
||||
|
||||
impl<'a> Drawing<'a> {
|
||||
/// Read the sections of `pairs`.
|
||||
pub fn read(pairs: &'a [Pair<'a>]) -> Self {
|
||||
let mut d = Drawing { header: Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }, ..Drawing::default() };
|
||||
let all = records(pairs);
|
||||
let mut section: Option<&str> = None;
|
||||
let (mut tables, mut blocks, mut objects) = (vec![], vec![], vec![]);
|
||||
for r in all {
|
||||
match (r.kind, section) {
|
||||
("SECTION", _) => {
|
||||
d.sections += 1;
|
||||
section = r.g.name(2);
|
||||
if section == Some("HEADER") {
|
||||
d.header = header(r.g);
|
||||
}
|
||||
}
|
||||
("ENDSEC", _) => section = None,
|
||||
(_, Some("TABLES")) => tables.push(r),
|
||||
(_, Some("BLOCKS")) => blocks.push(r),
|
||||
(_, Some("ENTITIES")) => d.entities.push(r),
|
||||
(_, Some("OBJECTS")) => objects.push(r),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut block_records = HashMap::new();
|
||||
let mut table = "";
|
||||
for r in &tables {
|
||||
match r.kind {
|
||||
"TABLE" => table = r.g.name(2).unwrap_or(""),
|
||||
"LAYER" if table == "LAYER" => {
|
||||
if let Some(l) = layer(r.g) {
|
||||
d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len());
|
||||
d.layers.push(l);
|
||||
}
|
||||
}
|
||||
"LTYPE" if table == "LTYPE" => {
|
||||
if let Some(name) = r.g.name(2) {
|
||||
let pattern: Vec<f64> = r.g.iter().filter(|p| p.code == 49).filter_map(|p| number(p.value)).collect();
|
||||
d.linetypes.insert(name.to_uppercase(), pattern);
|
||||
}
|
||||
}
|
||||
"STYLE" if table == "STYLE" => {
|
||||
if let Some(name) = r.g.name(2) {
|
||||
d.styles.insert(name.to_uppercase(), font_family(r.g));
|
||||
}
|
||||
}
|
||||
"BLOCK_RECORD" if table == "BLOCK_RECORD" => {
|
||||
if let (Some(h), Some(name)) = (r.g.name(5), r.g.name(2)) {
|
||||
block_records.insert(h.to_uppercase(), name.to_uppercase());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
d.read_blocks(&blocks, &mut block_records);
|
||||
d.layouts = layouts(&objects, &block_records, &d.entities);
|
||||
d
|
||||
}
|
||||
/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black.
|
||||
pub(crate) fn aci_paper(i: u8) -> [u8; 3] {
|
||||
if i == 7 { [0; 3] } else { model::aci::rgb(i) }
|
||||
}
|
||||
|
||||
fn read_blocks(&mut self, records: &[Record<'a>], block_records: &mut HashMap<String, String>) {
|
||||
let mut current: Option<BlockDef<'a>> = None;
|
||||
for r in records {
|
||||
match r.kind {
|
||||
"BLOCK" => {
|
||||
let name = r.g.name(2).or_else(|| r.g.name(3)).unwrap_or("").to_string();
|
||||
// R13+ blocks name their block record: layouts find their block through it.
|
||||
if let Some(owner) = r.g.name(330) {
|
||||
block_records.entry(owner.to_uppercase()).or_insert_with(|| name.to_uppercase());
|
||||
}
|
||||
let xref = r.g.int_or(70, 0) & 4 != 0;
|
||||
current = Some(BlockDef { name, base: r.g.point(10), records: vec![], xref });
|
||||
}
|
||||
"ENDBLK" => {
|
||||
if let Some(b) = current.take().filter(|b| !b.name.is_empty()) {
|
||||
self.blocks.insert(b.name.to_uppercase(), b);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(b) = &mut current {
|
||||
b.records.push(*r);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'a> Drawing<'a> {
|
||||
/// What the import uses of `doc`.
|
||||
pub fn read(doc: model::Document<'a>) -> Self {
|
||||
let h = &doc.header;
|
||||
let header = Header {
|
||||
acadver: h.acadver().unwrap_or_default(),
|
||||
insunits: h.insunits(),
|
||||
measurement: h.measurement(),
|
||||
ltscale: h.ltscale(),
|
||||
lwdefault: i32::from(h.lwdefault()),
|
||||
};
|
||||
let mut d = Drawing { header, sections: doc.sections, ..Drawing::default() };
|
||||
for l in doc.layers() {
|
||||
d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len());
|
||||
d.layers.push(layer(l));
|
||||
}
|
||||
for lt in doc.linetypes() {
|
||||
d.linetypes.insert(lt.name.to_uppercase(), lt.pattern.iter().map(|e| e.length).collect());
|
||||
}
|
||||
for st in doc.text_styles().into_iter().filter(|s| !s.name.is_empty()) {
|
||||
d.styles.insert(st.name.to_uppercase(), st.family().unwrap_or_default());
|
||||
}
|
||||
let block_names = doc.block_names();
|
||||
d.layouts = layouts(&doc, &block_names);
|
||||
for b in doc.blocks {
|
||||
if b.name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let def = BlockDef { name: b.name.to_string(), base: point(b.base()), xref: b.flags() & 4 != 0, records: b.entities };
|
||||
d.blocks.insert(b.name.to_uppercase(), def);
|
||||
}
|
||||
d.entities = doc.entities;
|
||||
d
|
||||
}
|
||||
|
||||
/// The layout named `name` (any case; `model` is model space).
|
||||
@@ -317,7 +129,7 @@ impl<'a> Drawing<'a> {
|
||||
}
|
||||
|
||||
/// The entity records of `layout`.
|
||||
pub fn layout_records(&self, layout: &LayoutDef) -> Vec<Record<'a>> {
|
||||
pub fn layout_records(&self, layout: &LayoutDef) -> Vec<EntityRecord<'a>> {
|
||||
let block = |name: &str| self.blocks.get(name).map(|b| b.records.clone()).unwrap_or_default();
|
||||
// The active paper layout keeps its art in the ENTITIES section, flagged paper space.
|
||||
let (block_name, paper) = match &layout.block {
|
||||
@@ -325,7 +137,7 @@ impl<'a> Drawing<'a> {
|
||||
Some(b) if b == PAPER_BLOCK => (PAPER_BLOCK, true),
|
||||
Some(b) => return block(b),
|
||||
};
|
||||
let mut out: Vec<Record<'a>> = groups(&self.entities).filter(|g| in_paper_space(g) == paper).flatten().copied().collect();
|
||||
let mut out: Vec<EntityRecord<'a>> = self.entities.iter().filter(|e| e.paper_space() == paper).cloned().collect();
|
||||
out.extend(block(block_name));
|
||||
out
|
||||
}
|
||||
@@ -336,111 +148,47 @@ impl<'a> Drawing<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is an entity (its first record) in paper space?
|
||||
fn in_paper_space(group: &[Record<'_>]) -> bool {
|
||||
group.first().is_some_and(|r| r.g.int(67) == Some(1))
|
||||
pub(crate) fn point(p: model::Point) -> Point {
|
||||
Point::new(p[0], p[1])
|
||||
}
|
||||
|
||||
/// The entities of `records`, each with the records that belong to it (a polyline's vertices,
|
||||
/// an insert's attributes, and the SEQEND closing them).
|
||||
pub(crate) fn groups<'r, 'a>(records: &'r [Record<'a>]) -> impl Iterator<Item = &'r [Record<'a>]> {
|
||||
let mut i = 0;
|
||||
std::iter::from_fn(move || {
|
||||
let start = i;
|
||||
let head = records.get(start)?;
|
||||
i += 1;
|
||||
if matches!(head.kind, "POLYLINE" | "INSERT") {
|
||||
while let Some(r) = records.get(i) {
|
||||
match r.kind {
|
||||
"VERTEX" | "ATTRIB" => i += 1,
|
||||
"SEQEND" => {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
records.get(start..i)
|
||||
})
|
||||
}
|
||||
|
||||
fn header(g: &[Pair<'_>]) -> Header {
|
||||
let mut h = Header { ltscale: 1.0, lwdefault: 25, ..Header::default() };
|
||||
let mut var = "";
|
||||
for p in g {
|
||||
if p.code == 9 {
|
||||
var = p.value.trim();
|
||||
continue;
|
||||
}
|
||||
match (var, p.code) {
|
||||
("$ACADVER", 1) => h.acadver = p.value.trim().to_string(),
|
||||
("$INSUNITS", 70) => h.insunits = integer(p.value).unwrap_or(0),
|
||||
("$MEASUREMENT", 70) => h.measurement = integer(p.value),
|
||||
("$LTSCALE", 40) => h.ltscale = number(p.value).filter(|v| *v > 0.0).unwrap_or(1.0),
|
||||
("$LWDEFAULT", 370) => h.lwdefault = integer(p.value).and_then(|v| i32::try_from(v).ok()).filter(|v| *v >= 0).unwrap_or(25),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn layer(g: &[Pair<'_>]) -> Option<LayerDef> {
|
||||
let name = g.name(2)?.to_string();
|
||||
let aci = g.int_or(62, 7);
|
||||
let flags = g.int_or(70, 0);
|
||||
let rgb = super::entity::true_color(g).unwrap_or_else(|| super::entity::aci_paper(aci.unsigned_abs()));
|
||||
Some(LayerDef {
|
||||
fn layer(l: model::Layer) -> LayerDef {
|
||||
let rgb = match l.color {
|
||||
model::Color::Rgb(rgb) => rgb,
|
||||
model::Color::Index(i) => aci_paper(i),
|
||||
model::Color::ByLayer | model::Color::ByBlock => aci_paper(7),
|
||||
};
|
||||
LayerDef {
|
||||
// CAD apps never plot this layer of dimension points.
|
||||
plot: g.int_or(290, 1) != 0 && !name.eq_ignore_ascii_case("Defpoints"),
|
||||
name,
|
||||
plot: l.plot && !l.name.eq_ignore_ascii_case("Defpoints"),
|
||||
rgb,
|
||||
hidden: aci < 0 || flags & 1 != 0,
|
||||
locked: flags & 4 != 0,
|
||||
linetype: g.name(6).unwrap_or("CONTINUOUS").to_uppercase(),
|
||||
lineweight: g.int(370).and_then(|v| i32::try_from(v).ok()).unwrap_or(-3),
|
||||
})
|
||||
}
|
||||
|
||||
/// A text style's font family: the one 2000-and-later files name, else the font file's name
|
||||
/// (TrueType); CAD fonts (`.shx`) have none.
|
||||
fn font_family(g: &[Pair<'_>]) -> String {
|
||||
if let Some(i) = g.iter().position(|p| p.code == 1001 && p.value.trim() == "ACAD")
|
||||
&& let Some(f) = g.get(i + 1..).and_then(|rest| rest.name(1000))
|
||||
{
|
||||
return f.to_string();
|
||||
}
|
||||
let file = g.name(3).unwrap_or("");
|
||||
let path = std::path::Path::new(file);
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
"ttf" | "otf" | "ttc" => path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
hidden: !l.on || l.frozen,
|
||||
locked: l.locked,
|
||||
linetype: l.linetype.to_uppercase(),
|
||||
lineweight: i32::try_from(l.lineweight.to_dxf()).unwrap_or(-3),
|
||||
name: l.name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout
|
||||
/// objects but with paper-space art has one paper layout.
|
||||
/// The most bytes of layout and block names the layouts keep together. Each layout keeps its own
|
||||
/// copy of its block's name, so many layouts naming one long block name multiplied it; real
|
||||
/// drawings have a few dozen layouts with short names.
|
||||
const MAX_LAYOUT_NAME_BYTES: usize = 4 << 20;
|
||||
|
||||
fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, entities: &[Record<'_>]) -> Vec<LayoutDef> {
|
||||
/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout
|
||||
/// objects but with paper-space art has one paper layout.
|
||||
fn layouts(doc: &model::Document<'_>, block_names: &HashMap<u64, &str>) -> Vec<LayoutDef> {
|
||||
let mut paper: Vec<(i64, LayoutDef)> = vec![];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut name_bytes = 0usize;
|
||||
for r in objects.iter().filter(|r| r.kind == "LAYOUT") {
|
||||
// The layout's own groups follow its subclass marker.
|
||||
let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]);
|
||||
let Some(name) = own.name(1) else { continue };
|
||||
let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase()));
|
||||
if name.eq_ignore_ascii_case(MODEL) || block.map(String::as_str) == Some(MODEL_BLOCK) {
|
||||
for l in doc.layouts() {
|
||||
let block = l.block_record.and_then(|h| block_names.get(&h)).map(|n| n.to_uppercase());
|
||||
if l.name.eq_ignore_ascii_case(MODEL) || block.as_deref() == Some(MODEL_BLOCK) {
|
||||
continue;
|
||||
}
|
||||
let Some(block) = block else { continue };
|
||||
let key = name.to_ascii_lowercase();
|
||||
let cost = name.len() + block.len();
|
||||
let key = l.name.to_ascii_lowercase();
|
||||
let cost = l.name.len() + block.len();
|
||||
// A layout past the allowance is skipped alone: a later short one is still offered, and a
|
||||
// skipped one reserves neither its name nor its bytes.
|
||||
if seen.contains(&key) || name_bytes.saturating_add(cost) > MAX_LAYOUT_NAME_BYTES {
|
||||
@@ -448,22 +196,11 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, enti
|
||||
}
|
||||
seen.insert(key);
|
||||
name_bytes += cost;
|
||||
paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block.clone()) }));
|
||||
paper.push((l.tab_order, LayoutDef { name: l.name, block: Some(block) }));
|
||||
}
|
||||
paper.sort_by_key(|(order, _)| *order);
|
||||
if paper.is_empty() && groups(entities).any(in_paper_space) {
|
||||
if paper.is_empty() && doc.entities.iter().any(EntityRecord::paper_space) {
|
||||
paper.push((1, LayoutDef { name: "Layout1".into(), block: Some(PAPER_BLOCK.into()) }));
|
||||
}
|
||||
std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::integer;
|
||||
|
||||
#[test]
|
||||
fn integer_rejects_fractional_float_values() {
|
||||
assert_eq!(integer("1.0"), Some(1));
|
||||
assert_eq!(integer("1.9"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,7 +997,12 @@ fn binary_dwg_damaged_and_foreign_files() {
|
||||
binary.extend([0u8; 40]);
|
||||
assert!(is_dxf(&binary));
|
||||
let e = import(&binary, &ImportOptions::default()).unwrap_err();
|
||||
assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}");
|
||||
assert!(e.contains("binary DXF") && e.contains("damaged"), "{e}");
|
||||
// A binary DXF opens as its ASCII form does.
|
||||
let ascii = entities(LINE);
|
||||
let tags = craft_dxf::parse(&ascii).unwrap();
|
||||
let r = open(&craft_dxf::write_binary(&tags, true), &points());
|
||||
assert_eq!(leaves(&r.document).len(), 1);
|
||||
let e = import(b"AC1032\x00\x00\x00", &ImportOptions::default()).unwrap_err();
|
||||
assert!(e.contains("DWG"), "{e}");
|
||||
for junk in [&b""[..], b"hello", b"<svg/>", b"999\nnote\n"] {
|
||||
@@ -1166,9 +1171,8 @@ fn many_array_inserts_of_a_missing_block_stay_bounded() {
|
||||
let one = [(0, "INSERT"), (8, "0"), (2, "NOPE"), (10, "0"), (20, "0"), (70, "100"), (71, "100"), (44, "1"), (45, "1")];
|
||||
let e: Vec<(i32, &str)> = (0..3000).flat_map(|_| one).collect();
|
||||
let bytes = entities(&e);
|
||||
let text = reader::decode(&bytes);
|
||||
let (pairs, _) = reader::pairs(&text).unwrap();
|
||||
let d = reader::Drawing::read(&pairs);
|
||||
let tags = craft_dxf::parse(&bytes).unwrap();
|
||||
let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags));
|
||||
let model = d.layout("Model").or(d.layouts.first()).unwrap();
|
||||
let mut conv = entity::Converter::default();
|
||||
let items = conv.items(&d.layout_records(model));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an
|
||||
//! HP-GL/2 plot file reader and writer.
|
||||
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and a reader on
|
||||
//! `craft_dxf::model`, and an HP-GL/2 plot file reader and writer.
|
||||
//!
|
||||
//! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF
|
||||
//! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths
|
||||
@@ -11,7 +11,7 @@
|
||||
//! What DXF can't hold (blending modes, opacity masks, raster effects, gradients, patterns,
|
||||
//! clipping) is approximated or left out, and each such loss comes back as a warning.
|
||||
//!
|
||||
//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`].
|
||||
//! [`import()`] reads a DXF drawing (ASCII or binary, any version) into a document: see [`import`].
|
||||
//! [`hpgl::import`] reads an HP-GL plotter file, and [`hpgl::export`] writes one: see [`hpgl`].
|
||||
//!
|
||||
//! - `aci`: the 256-colour index palette and the nearest index of a colour.
|
||||
|
||||
@@ -683,7 +683,7 @@ impl<'a> Scene<'a> {
|
||||
{
|
||||
// Symbols holding instances of symbols are written in full for each instance,
|
||||
// empty or not: every instance's art counts against one budget for the export.
|
||||
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n);
|
||||
let count = art.count_with_masks() as u64;
|
||||
// Counted as `place_symbol_art_unclipped` places it: no clip groups.
|
||||
let most = doc.symbol_slice_map(symbol, *xf).map_or(count, |m| m.most_placed_unclipped(&art, doc));
|
||||
|
||||
@@ -1395,6 +1395,21 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() {
|
||||
assert!(!kinds(&export(&d, &o).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it.
|
||||
#[test]
|
||||
fn a_stained_instance_is_written_in_its_stained_color() {
|
||||
let mut d = doc_with(|d| {
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((30.0, 30.0)) });
|
||||
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
|
||||
vec![n]
|
||||
});
|
||||
let art = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
let colors: Vec<Option<String>> = entities(&out).iter().map(|e| get(e, 420).map(str::to_string)).collect();
|
||||
assert!(!colors.is_empty() && colors.iter().all(|c| c.as_deref() == Some("255")), "{colors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() {
|
||||
// Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty
|
||||
|
||||
+61
-20
@@ -763,33 +763,19 @@ impl Node {
|
||||
children.iter().rposition(|c| !c.is_ruler_guide()).map_or(0, |i| i + 1)
|
||||
}
|
||||
|
||||
/// Remove the ruler guides of this layer and of its sublayers. Other nodes hold none.
|
||||
/// Remove the ruler guides of this layer and of its sublayers. Other nodes hold none. Only
|
||||
/// layers that hold one are copied.
|
||||
pub fn strip_ruler_guides(&mut self) {
|
||||
if !self.holds_ruler_guides() {
|
||||
return;
|
||||
}
|
||||
if let Some(ch) = self.children_mut() {
|
||||
ch.retain(|c| !c.is_ruler_guide());
|
||||
for c in ch.iter_mut().filter(|c| c.holds_ruler_guides()) {
|
||||
Arc::make_mut(c).strip_ruler_guides();
|
||||
}
|
||||
if let Some(held) = held_guides(self, &Self::is_ruler_guide) {
|
||||
strip_held(self, held);
|
||||
}
|
||||
}
|
||||
|
||||
/// Call `f` on the position (along the axis it crosses) of each ruler guide of this layer and
|
||||
/// of its sublayers, with whether the guide is vertical. Only layers that hold one are copied.
|
||||
pub fn map_ruler_guide_positions(&mut self, f: &mut impl FnMut(bool, &mut f64)) {
|
||||
if !self.holds_ruler_guides() {
|
||||
return;
|
||||
}
|
||||
if let Some(ch) = self.children_mut() {
|
||||
for c in ch.iter_mut().filter(|c| c.is_ruler_guide() || c.holds_ruler_guides()) {
|
||||
let c = Arc::make_mut(c);
|
||||
match &mut c.kind {
|
||||
NodeKind::RulerGuide { vertical, pos, .. } => f(*vertical, pos),
|
||||
_ => c.map_ruler_guide_positions(f),
|
||||
}
|
||||
}
|
||||
if let Some(held) = held_guides(self, &Self::is_ruler_guide) {
|
||||
map_held(self, held, f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -834,6 +820,61 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ruler guides a layer holds: its children that are one, by index in order (each with
|
||||
/// nothing inside), and its sublayers holding one (with what they hold).
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct HeldGuides(Vec<(usize, HeldGuides)>);
|
||||
|
||||
/// The ruler guides, by `is_guide`, that `n` holds if it is a layer, or `None` when it holds
|
||||
/// none. `is_guide` looks at each child of `n` and of its sublayers once.
|
||||
pub(super) fn held_guides(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option<HeldGuides> {
|
||||
if !n.is_layer() {
|
||||
return None;
|
||||
}
|
||||
let held: Vec<_> = n
|
||||
.children()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| if is_guide(c) { Some((i, HeldGuides::default())) } else { held_guides(c, is_guide).map(|h| (i, h)) })
|
||||
.collect();
|
||||
(!held.is_empty()).then_some(HeldGuides(held))
|
||||
}
|
||||
|
||||
/// Remove the ruler guides of `layer` and of its sublayers that [`held_guides`] found (`held`).
|
||||
/// Only the sublayers holding one are copied, and only those are visited.
|
||||
pub(super) fn strip_held(layer: &mut Node, held: HeldGuides) {
|
||||
let Some(ch) = layer.children_mut() else { return };
|
||||
let mut held = held.0.into_iter().peekable();
|
||||
let mut next = 0;
|
||||
ch.retain_mut(|c| {
|
||||
let at = next;
|
||||
next += 1;
|
||||
let Some((_, inner)) = held.next_if(|(i, _)| *i == at) else { return true };
|
||||
// A sublayer holds something; a ruler guide has nothing inside.
|
||||
if inner.0.is_empty() {
|
||||
return false;
|
||||
}
|
||||
strip_held(Arc::make_mut(c), inner);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// Call `f` (whether it is vertical, its position) on each ruler guide of `layer` and of its
|
||||
/// sublayers that [`held_guides`] found (`held`), in the order they lie in. Only the sublayers
|
||||
/// holding one, and the guides, are copied, and only those sublayers are visited.
|
||||
pub(super) fn map_held(layer: &mut Node, held: HeldGuides, f: &mut impl FnMut(bool, &mut f64)) {
|
||||
let Some(ch) = layer.children_mut() else { return };
|
||||
for (i, inner) in held.0 {
|
||||
let Some(c) = ch.get_mut(i) else { continue };
|
||||
if !inner.0.is_empty() {
|
||||
map_held(Arc::make_mut(c), inner, f);
|
||||
} else if let NodeKind::RulerGuide { vertical, pos, .. } = &mut Arc::make_mut(c).kind {
|
||||
f(*vertical, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// The Appearance panel's row for what this object's own fills and strokes paint around:
|
||||
/// "Contents" for a group or layer (its members) and for an image (its pixels), "Characters"
|
||||
|
||||
@@ -598,3 +598,207 @@ fn compound_stroke_path_is_its_members_in_order() {
|
||||
assert_eq!(bp.elements(), expected.elements());
|
||||
assert!(!bp.elements().is_empty());
|
||||
}
|
||||
|
||||
/// The sublayers in [`guide_layers`]'s chain, each inside the one before.
|
||||
const GUIDE_CHAIN: u64 = 30;
|
||||
|
||||
/// How many children the layers of [`guide_layers`] hold: the layer's 6, 1 in the sublayer
|
||||
/// without a guide, 2 at each of the two places the shared sublayer lies, the innermost
|
||||
/// sublayer's 2 and 3 in each sublayer around it.
|
||||
const GUIDE_LAYER_CHILDREN: usize = 6 + 1 + 2 * 2 + 2 + (GUIDE_CHAIN as usize - 1) * 3;
|
||||
|
||||
/// How many ruler guides the layers of [`guide_layers`] hold: one in each sublayer of the chain,
|
||||
/// one at each place the shared sublayer lies and the layer's own.
|
||||
const GUIDE_LAYER_GUIDES: usize = GUIDE_CHAIN as usize + 2 + 1;
|
||||
|
||||
/// A layer holding, in order: [`GUIDE_CHAIN`] sublayers, each inside the one before and holding an
|
||||
/// object and a ruler guide; a sublayer with an object only; a sublayer with an object and a
|
||||
/// guide; an object; that last sublayer again; and a guide. Guides differ in direction, artboard
|
||||
/// and lock. Returns the layer, the sublayer without a guide and the one that lies twice.
|
||||
fn guide_layers() -> (Node, Arc<Node>, Arc<Node>) {
|
||||
let rect = |id| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()));
|
||||
let guide = |id: u64| {
|
||||
let kind = NodeKind::RulerGuide { vertical: id.is_multiple_of(2), pos: id as f64, artboard: id.is_multiple_of(3).then_some(7) };
|
||||
let mut g = Node::new(NodeId(id), kind);
|
||||
g.locked = id.is_multiple_of(5);
|
||||
Arc::new(g)
|
||||
};
|
||||
let sublayer = |id, children: Vec<Arc<Node>>| {
|
||||
let mut l = Node::layer(NodeId(id), "Sub", LayerColor::Preset(1));
|
||||
if let Some(ch) = l.children_mut() {
|
||||
*ch = children;
|
||||
}
|
||||
Arc::new(l)
|
||||
};
|
||||
let mut chain = sublayer(100, vec![rect(150), guide(200)]);
|
||||
for depth in 1..GUIDE_CHAIN {
|
||||
chain = sublayer(100 + depth, vec![rect(600 + depth), chain, guide(300 + depth)]);
|
||||
}
|
||||
let still = sublayer(500, vec![rect(501)]);
|
||||
let twice = sublayer(510, vec![rect(511), guide(512)]);
|
||||
let mut layer = Node::layer(NodeId(1), "Layer", LayerColor::Preset(0));
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = vec![chain, Arc::clone(&still), Arc::clone(&twice), rect(2), Arc::clone(&twice), guide(3)];
|
||||
}
|
||||
(layer, still, twice)
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` hold the same children, none of them copied.
|
||||
fn same_children(a: &Node, b: &Node) -> bool {
|
||||
let (a, b) = (a.children().unwrap(), b.children().unwrap());
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b))
|
||||
}
|
||||
|
||||
/// Stripping the ruler guides of a layer looks at each child of the layer and of its sublayers
|
||||
/// once, however deep the sublayers nest. The art and its order stay, a sublayer without a guide is
|
||||
/// not copied, and a copy of the layer held elsewhere keeps its guides.
|
||||
#[test]
|
||||
fn stripping_ruler_guides_looks_at_each_child_of_nested_layers_once() {
|
||||
use std::cell::Cell;
|
||||
let (whole, still, twice) = guide_layers();
|
||||
let ids = |n: &Node| {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| out.push(c.id));
|
||||
out
|
||||
};
|
||||
let mut art = vec![];
|
||||
whole.walk(&mut |c| {
|
||||
if !c.is_ruler_guide() {
|
||||
art.push(c.id);
|
||||
}
|
||||
});
|
||||
assert_eq!(ids(&whole).len(), art.len() + GUIDE_LAYER_GUIDES);
|
||||
|
||||
let mut n = whole.clone();
|
||||
let looked = Cell::new(0_usize);
|
||||
let pick = |c: &Node| {
|
||||
looked.set(looked.get() + 1);
|
||||
c.is_ruler_guide()
|
||||
};
|
||||
let held = super::ops::held_guides(&n, &pick).unwrap();
|
||||
assert_eq!(looked.get(), GUIDE_LAYER_CHILDREN, "each child of the layers is looked at once");
|
||||
super::ops::strip_held(&mut n, held);
|
||||
assert_eq!(looked.get(), GUIDE_LAYER_CHILDREN, "removing looks at nothing again");
|
||||
assert_eq!(ids(&n), art, "the art stays, in its order");
|
||||
let ch = n.children().unwrap();
|
||||
assert!(Arc::ptr_eq(&ch[1], &still), "a sublayer without a guide is not copied");
|
||||
assert!(Arc::ptr_eq(&ch[3], &whole.children().unwrap()[3]), "an object is not copied");
|
||||
assert!(!Arc::ptr_eq(&ch[2], &twice) && !Arc::ptr_eq(&ch[4], &twice) && !Arc::ptr_eq(&ch[2], &ch[4]));
|
||||
assert_eq!(
|
||||
(ch[2].children().map(Vec::len), ch[4].children().map(Vec::len)),
|
||||
(Some(1), Some(1)),
|
||||
"a sublayer lying twice is stripped at both places"
|
||||
);
|
||||
// The copies held elsewhere (an undo step's, say) keep their guides.
|
||||
assert_eq!(twice.children().map(Vec::len), Some(2));
|
||||
assert_eq!(ids(&whole).len(), art.len() + GUIDE_LAYER_GUIDES);
|
||||
|
||||
// The method strips the same guides.
|
||||
let mut same = whole.clone();
|
||||
same.strip_ruler_guides();
|
||||
assert_eq!(same, n);
|
||||
assert!(Arc::ptr_eq(&same.children().unwrap()[1], &still));
|
||||
// With no guide left there is nothing to remove, and nothing is copied.
|
||||
let before = n.clone();
|
||||
n.strip_ruler_guides();
|
||||
assert!(same_children(&n, &before));
|
||||
let mut empty = Node::layer(NodeId(900), "Empty", LayerColor::Preset(0));
|
||||
empty.strip_ruler_guides();
|
||||
assert_eq!(empty.children().map(Vec::len), Some(0));
|
||||
// Only a layer holds ruler guides: a group is left as it is, on its own and in a layer.
|
||||
let mut group = Node::group(NodeId(901), vec![Arc::clone(&twice)]);
|
||||
assert!(super::ops::held_guides(&group, &Node::is_ruler_guide).is_none());
|
||||
group.strip_ruler_guides();
|
||||
assert!(Arc::ptr_eq(&group.children().unwrap()[0], &twice));
|
||||
let group = Arc::new(group);
|
||||
let mut around = Node::layer(NodeId(902), "Around", LayerColor::Preset(0));
|
||||
around.children_mut().unwrap().push(Arc::clone(&group));
|
||||
around.strip_ruler_guides();
|
||||
assert!(Arc::ptr_eq(&around.children().unwrap()[0], &group));
|
||||
}
|
||||
|
||||
/// Changing the positions of the ruler guides of a layer looks at each child of the layer and of
|
||||
/// its sublayers once, however deep the sublayers nest. Each guide is changed once, in the order
|
||||
/// the guides lie in, and keeps everything else about it; the art is unchanged, a sublayer without
|
||||
/// a guide is not copied, and a copy of the layer held elsewhere keeps its positions.
|
||||
#[test]
|
||||
fn mapping_ruler_guide_positions_looks_at_each_child_of_nested_layers_once() {
|
||||
use std::cell::Cell;
|
||||
let (whole, still, twice) = guide_layers();
|
||||
let mut lines = vec![];
|
||||
whole.walk(&mut |c| {
|
||||
if let NodeKind::RulerGuide { vertical, pos, .. } = c.kind {
|
||||
lines.push((vertical, pos));
|
||||
}
|
||||
});
|
||||
assert_eq!(lines.len(), GUIDE_LAYER_GUIDES);
|
||||
assert!(lines.iter().any(|l| l.0) && lines.iter().any(|l| !l.0), "guides in both directions");
|
||||
|
||||
let mut n = whole.clone();
|
||||
let looked = Cell::new(0_usize);
|
||||
let pick = |c: &Node| {
|
||||
looked.set(looked.get() + 1);
|
||||
c.is_ruler_guide()
|
||||
};
|
||||
let held = super::ops::held_guides(&n, &pick).unwrap();
|
||||
assert_eq!(looked.get(), GUIDE_LAYER_CHILDREN, "each child of the layers is looked at once");
|
||||
let mut seen = vec![];
|
||||
super::ops::map_held(&mut n, held, &mut |vertical, pos| {
|
||||
seen.push((vertical, *pos));
|
||||
*pos = *pos * 2.0 + 1.0;
|
||||
});
|
||||
assert_eq!(looked.get(), GUIDE_LAYER_CHILDREN, "changing looks at nothing again");
|
||||
assert_eq!(seen, lines, "each guide once, in the order the guides lie in");
|
||||
|
||||
// The same nodes in the same order: a guide differs in its position alone, and the art not
|
||||
// at all.
|
||||
let (mut was, mut now) = (vec![], vec![]);
|
||||
whole.walk(&mut |c| was.push(c));
|
||||
n.walk(&mut |c| now.push(c));
|
||||
assert_eq!(was.len(), now.len());
|
||||
for (a, b) in was.into_iter().zip(now) {
|
||||
if a.is_layer() {
|
||||
assert_eq!((b.id, b.is_layer(), b.children().map(Vec::len)), (a.id, true, a.children().map(Vec::len)));
|
||||
continue;
|
||||
}
|
||||
let mut want = a.clone();
|
||||
if let NodeKind::RulerGuide { pos, .. } = &mut want.kind {
|
||||
*pos = *pos * 2.0 + 1.0;
|
||||
}
|
||||
assert_eq!(b, &want);
|
||||
}
|
||||
let ch = n.children().unwrap();
|
||||
assert!(Arc::ptr_eq(&ch[1], &still), "a sublayer without a guide is not copied");
|
||||
assert!(Arc::ptr_eq(&ch[3], &whole.children().unwrap()[3]), "an object is not copied");
|
||||
assert!(!Arc::ptr_eq(&ch[2], &twice) && !Arc::ptr_eq(&ch[4], &twice) && !Arc::ptr_eq(&ch[2], &ch[4]));
|
||||
// The copy held elsewhere keeps its position.
|
||||
assert!(matches!(twice.children().unwrap()[1].kind, NodeKind::RulerGuide { pos, .. } if pos == 512.0));
|
||||
|
||||
// The method changes the same guides, each once.
|
||||
let mut same = whole.clone();
|
||||
let mut calls = 0;
|
||||
same.map_ruler_guide_positions(&mut |_, pos| {
|
||||
calls += 1;
|
||||
*pos = *pos * 2.0 + 1.0;
|
||||
});
|
||||
assert_eq!((calls, &same), (GUIDE_LAYER_GUIDES, &n));
|
||||
assert!(Arc::ptr_eq(&same.children().unwrap()[1], &still));
|
||||
// A function that leaves the positions as they are is still called on each guide.
|
||||
let mut kept = whole.clone();
|
||||
let mut calls = 0;
|
||||
kept.map_ruler_guide_positions(&mut |_, _| calls += 1);
|
||||
assert_eq!((calls, &kept), (GUIDE_LAYER_GUIDES, &whole));
|
||||
// A layer without a guide, an empty layer and a group: nothing to change, nothing copied.
|
||||
let mut bare = whole.clone();
|
||||
bare.strip_ruler_guides();
|
||||
let before = bare.clone();
|
||||
let mut calls = 0;
|
||||
bare.map_ruler_guide_positions(&mut |_, _| calls += 1);
|
||||
assert!(same_children(&bare, &before));
|
||||
let mut empty = Node::layer(NodeId(900), "Empty", LayerColor::Preset(0));
|
||||
empty.map_ruler_guide_positions(&mut |_, _| calls += 1);
|
||||
let mut group = Node::group(NodeId(901), vec![Arc::clone(&twice)]);
|
||||
group.map_ruler_guide_positions(&mut |_, _| calls += 1);
|
||||
assert!(Arc::ptr_eq(&group.children().unwrap()[0], &twice));
|
||||
assert_eq!(calls, 0);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! square to it: scaling by a side handle stretches it along its own width or height. Path
|
||||
//! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt};
|
||||
|
||||
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
@@ -84,7 +86,35 @@ pub fn transformed_angle(angle: f64, a: Affine) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The turn (counter-clockwise degrees) from the boxes of art placed by a symbol instance's
|
||||
/// transform `xf` to the instance's own box at `angle`: 0 while the box turns with the transform,
|
||||
/// otherwise the change Reset Bounding Box made to it. Under a mirroring `xf` the instance's box can
|
||||
/// stand 180° from the one `xf` gives in one step ([`transformed_angle`] keeps the angle nearer the
|
||||
/// one before at each step); the turn is then 180°, so the art's boxes match the instance's.
|
||||
pub fn placed_turn(angle: f64, xf: Affine) -> f64 {
|
||||
let turn = normalize_deg(angle - transformed_angle(0.0, xf));
|
||||
if turn.is_finite() && turn.abs() > EPS_DEG { turn } else { 0.0 }
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Turns the box of this object, and of every object that turns with it ([`Node::transform`]:
|
||||
/// its children and the art of a linked opacity mask), by `by` degrees counter-clockwise.
|
||||
/// Layers have no box.
|
||||
pub fn turn_boxes(&mut self, by: f64) {
|
||||
if !self.is_layer() {
|
||||
let a = normalize_deg(self.bbox_angle + by);
|
||||
self.bbox_angle = if a.abs() <= EPS_DEG { 0.0 } else { a };
|
||||
}
|
||||
for c in self.children_mut().into_iter().flatten() {
|
||||
Arc::make_mut(c).turn_boxes(by);
|
||||
}
|
||||
if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut())
|
||||
&& m.linked
|
||||
{
|
||||
Arc::make_mut(&mut m.art).turn_boxes(by);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes),
|
||||
/// without changing it, symbol instances measured by their art from `symbols`
|
||||
/// ([`Node::geometric_bounds_with`]).
|
||||
@@ -185,6 +215,44 @@ mod tests {
|
||||
assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_turn_is_what_reset_bounding_box_took_off_the_instance() {
|
||||
let xf = Affine::translate((40.0, 10.0)) * rot(20.0) * Affine::scale_non_uniform(2.0, 0.5);
|
||||
// The box turns with the transform: no turn.
|
||||
assert_eq!(placed_turn(transformed_angle(0.0, xf), xf), 0.0);
|
||||
assert_eq!(placed_turn(0.0, Affine::scale(3.0)), 0.0);
|
||||
// Reset to 0: the placed art turns back by the 20°.
|
||||
assert!(close(placed_turn(0.0, xf), -20.0));
|
||||
// Mirrored: turned 170° and then flipped, the instance's box stands 180° from the box the
|
||||
// whole transform gives in one step, and the art turns the half turn to match it.
|
||||
let flip = Affine::scale_non_uniform(1.0, -1.0);
|
||||
let flipped = flip * rot(170.0);
|
||||
let stepwise = transformed_angle(transformed_angle(0.0, rot(170.0)), flip);
|
||||
assert!(close(stepwise, -170.0) && close(transformed_angle(0.0, flipped), 10.0));
|
||||
assert!(close(placed_turn(stepwise, flipped).abs(), 180.0));
|
||||
assert!(close(normalize_deg(placed_turn(0.0, flipped) + transformed_angle(0.0, flipped)), 0.0));
|
||||
// Degenerate and non-finite transforms turn nothing.
|
||||
assert_eq!(placed_turn(0.0, Affine::scale(0.0)), 0.0);
|
||||
assert_eq!(placed_turn(f64::NAN, xf), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_boxes_turns_children_and_linked_mask_art_but_not_layers() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
let mut g = Node::group(d.alloc_id(), vec![Arc::new(a.clone())]);
|
||||
g.bbox_angle = 170.0;
|
||||
g.extras_mut().mask = Some(Box::new(crate::OpacityMask::new(a.clone(), true)));
|
||||
let mut layer = (*d.layers[0]).clone();
|
||||
layer.children_mut().unwrap().push(Arc::new(g));
|
||||
layer.turn_boxes(20.0);
|
||||
assert_eq!(layer.bbox_angle, 0.0);
|
||||
let g = &layer.children().unwrap()[0];
|
||||
assert!(close(g.bbox_angle, -170.0), "wraps to (-180, 180]");
|
||||
assert!(close(g.children().unwrap()[0].bbox_angle, 20.0));
|
||||
assert!(close(g.extras().mask.as_ref().unwrap().art.bbox_angle, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oriented_bounds_hug_a_rotated_rectangle() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
|
||||
@@ -614,6 +614,8 @@ impl NineSliceMap {
|
||||
/// `n`, whose box at the natural size is `b`, drawn in each cell it reaches: a clip group of
|
||||
/// the cell around a copy placed by the cell's map, all in a group with `n`'s id. `n` placed
|
||||
/// by `center` when it draws symbol art ([`draws_symbol_art`]), and in [`Self::place_unclipped`].
|
||||
/// The clip groups and clip paths take the angle of the cell map that places them. The group
|
||||
/// and each copy keep `n`'s box angle, turned by the map that places them.
|
||||
///
|
||||
/// The drop shadows, outer glows and blurs ([`PAINTED_EFFECTS`]) at the end of `n`'s own
|
||||
/// effects, after its last other visible effect, go on that group with its opacity, blending
|
||||
@@ -647,11 +649,15 @@ impl NineSliceMap {
|
||||
area.transform(local);
|
||||
let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area, rule: FillRule::NonZero, live: None, clipping: true, guide: false });
|
||||
clip.appearance = Appearance::default().into();
|
||||
clip.bbox_angle = crate::orient::transformed_angle(0.0, local);
|
||||
let copy = transformed(n, local * self.natural);
|
||||
cells.push(Arc::new(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true })));
|
||||
let mut cell = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true });
|
||||
cell.bbox_angle = crate::orient::transformed_angle(0.0, local);
|
||||
cells.push(Arc::new(cell));
|
||||
}
|
||||
}
|
||||
let mut out = Node::group(n.id, cells);
|
||||
out.bbox_angle = crate::orient::transformed_angle(n.bbox_angle, center);
|
||||
out.visible = n.visible;
|
||||
out.locked = n.locked;
|
||||
out.name = n.name.clone();
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
//! symbol keeps appearance changes of its own to objects in the symbol's art, while the art stays
|
||||
//! the symbol's. Editing the symbol reshapes every instance and each keeps its changes; Reset drops
|
||||
//! them. The changes are stored on the instance ([`crate::node::NodeExtras::symbol_overrides`]) and
|
||||
//! applied wherever an instance is drawn or exported ([`dynamic_art`]).
|
||||
//! applied wherever an instance is drawn or exported ([`dynamic_art`]), and an instance's fill
|
||||
//! stains the art after them ([`instance_art`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
|
||||
use crate::{Node, NodeKind};
|
||||
use crate::{AppearanceItem, Node, NodeKind};
|
||||
|
||||
/// Deepest object an override may name: groups nested this deep in the symbol's art.
|
||||
pub const MAX_PATH_DEPTH: usize = 64;
|
||||
@@ -150,6 +151,52 @@ pub fn dynamic_art(art: &Node, inst: &Node) -> Node {
|
||||
a
|
||||
}
|
||||
|
||||
/// Mix every solid paint under `n` towards `color` by `amount` (0..1): the Symbol Stainer.
|
||||
pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
|
||||
let amount = amount.clamp(0.0, 1.0);
|
||||
for it in &mut n.appearance.items {
|
||||
let p = match it {
|
||||
AppearanceItem::Fill(f) => &mut f.paint,
|
||||
AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Solid { color: c, .. } = p {
|
||||
*p = Paint::solid(c.lerp(color, amount));
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
for p in [&mut r.style.fill, &mut r.style.stroke] {
|
||||
if let Paint::Solid { color: c, .. } = p {
|
||||
*p = Paint::solid(c.lerp(color, amount));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
tint_node(Arc::make_mut(c), color, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stain instance `inst` puts on its symbol's art: its visible fill color and that fill's
|
||||
/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one
|
||||
/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links.
|
||||
pub fn stain(inst: &Node) -> Option<(Color, f32)> {
|
||||
let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?;
|
||||
Some((f.paint.color()?, f.opacity))
|
||||
}
|
||||
|
||||
/// A symbol's art for instance `inst`, as the canvas draws it: with the instance's own appearance
|
||||
/// changes to it ([`dynamic_art`]), stained by the instance's fill ([`stain`]).
|
||||
pub fn instance_art(art: &Node, inst: &Node) -> Node {
|
||||
let mut a = dynamic_art(art, inst);
|
||||
if let Some((c, amount)) = stain(inst) {
|
||||
tint_node(&mut a, &c, amount);
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Keep the overrides that name an object of `art` (a symbol's new art), dropping the rest.
|
||||
pub fn retain_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) {
|
||||
overrides.retain(|o| part(art, &o.path).is_some());
|
||||
@@ -159,7 +206,6 @@ pub fn retain_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Appearance, NodeId};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
fn square(id: u64) -> Arc<Node> {
|
||||
|
||||
@@ -523,13 +523,14 @@ fn color_mask_keys<'a>(n: &'a Node, out: &mut Vec<&'a str>) {
|
||||
|
||||
/// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in
|
||||
/// `d` (named by their content), and a symbol instance stands for its symbol's art: `symbol_art`
|
||||
/// when given, otherwise the symbol's art with the instance's own appearance changes.
|
||||
/// when given, otherwise the symbol's art with the instance's own appearance changes, stained by
|
||||
/// the instance's fill ([`vectorcraft_doc::symbol_overrides::instance_art`]).
|
||||
pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option<Node>) -> Option<Node> {
|
||||
let art;
|
||||
let src = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => {
|
||||
let symbol = symbol_art
|
||||
.or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)));
|
||||
.or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::instance_art(&s.art, n)));
|
||||
art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?;
|
||||
&art
|
||||
}
|
||||
|
||||
@@ -148,9 +148,11 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
if let Some(m) = moved_type_group(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// A symbol instance's art as the canvas draws it: with its own appearance changes, stained
|
||||
// by its fill.
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n))
|
||||
d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::instance_art(&s.art, n))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -59,6 +59,46 @@ fn object_effects_become_geometry_and_raster_effects_stay() {
|
||||
assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow");
|
||||
}
|
||||
|
||||
/// The fill colors of the paths under the layers of `d`, in paint order.
|
||||
fn path_fills(d: &Document) -> Vec<Color> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Path { .. })
|
||||
&& let Some(c) = n.appearance.fill_paint().color()
|
||||
{
|
||||
out.push(c);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A symbol instance half stained red is baked in its stained colors, as the canvas draws it:
|
||||
/// moved by a Transform effect, and recolored by a color adjustment (Invert Colors) after the
|
||||
/// stain.
|
||||
#[test]
|
||||
fn a_stained_instance_is_baked_in_its_stained_colors() {
|
||||
let blue = Color::rgb8(30, 90, 220);
|
||||
let art = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(blue), Paint::None, 0.0));
|
||||
let mut inst = Node::new(NodeId(50), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: vectorcraft_geom::Affine::translate((20.0, 20.0)) });
|
||||
inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into();
|
||||
inst.appearance.items.retain(|i| i.is_fill());
|
||||
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut inst.appearance.items[0] {
|
||||
f.opacity = 0.5;
|
||||
}
|
||||
let stained = vectorcraft_doc::symbol_overrides::instance_art(&art, &inst).appearance.fill_paint().color().unwrap();
|
||||
assert_ne!(stained.to_hex(), blue.to_hex());
|
||||
let [r, g, b] = stained.to_rgb();
|
||||
let inverted = Color::rgb(1.0 - r, 1.0 - g, 1.0 - b);
|
||||
for (id, params, want) in [("distort.transform", json!({"moveH": 30.0}), stained), (crate::addons::color::INVERT, json!({}), inverted)] {
|
||||
let mut n = inst.clone();
|
||||
n.appearance.effects.push(fx(id, params));
|
||||
let mut d = doc_with(n);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art.clone()) });
|
||||
let fills = path_fills(&bake_document(&d).unwrap());
|
||||
assert_eq!(fills.iter().map(Color::to_hex).collect::<Vec<_>>(), [want.to_hex()], "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baked_transform_moves_a_placed_gradient_with_the_path() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
|
||||
@@ -23,15 +23,22 @@ pub fn text_outliner() -> Outliner<'static> {
|
||||
/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance,
|
||||
/// through the symbol's 9-slice guides when it has them ([`Document::place_symbol_art`], `None`
|
||||
/// past its object limit). As an [`OutlineHook`](crate::OutlineHook), it gives compound shapes and
|
||||
/// Pathfinder operations the symbol instances among their members.
|
||||
/// Pathfinder operations the symbol instances among their members. The group around the art takes
|
||||
/// the instance's box angle, and the art's boxes turn with it, as Break Link turns them.
|
||||
pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
let doc = doc?;
|
||||
let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
|
||||
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
let art = doc.place_symbol_art(symbol, &art, *xf)?;
|
||||
Some(Node::group(n.id, vec![Arc::new(art)]))
|
||||
let mut art = doc.place_symbol_art(symbol, &art, *xf)?;
|
||||
let turn = vectorcraft_doc::orient::placed_turn(n.bbox_angle, *xf);
|
||||
if turn != 0.0 {
|
||||
art.turn_boxes(turn);
|
||||
}
|
||||
let mut out = Node::group(n.id, vec![Arc::new(art)]);
|
||||
out.bbox_angle = n.bbox_angle;
|
||||
Some(out)
|
||||
}
|
||||
_ => outline_text(n),
|
||||
}
|
||||
|
||||
@@ -1570,6 +1570,27 @@ fn outline_object_is_a_geometry_effect_that_keeps_the_path() {
|
||||
assert_eq!(apply_geometry(&[fx("path.outlineObject", json!({})), offset.clone()], &square(), b), apply_geometry(&[offset], &square(), b));
|
||||
}
|
||||
|
||||
/// A symbol instance given to Pathfinder and compound shapes as its art: the group takes the
|
||||
/// instance's box angle and the art turns with it, turned with the instance or reset to 0.
|
||||
#[test]
|
||||
fn outline_object_turns_an_instances_art_with_its_box() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::orient::transformed_angle;
|
||||
use vectorcraft_doc::{Appearance, Document, NodeId, Symbol};
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
d.symbols.push(Symbol { dynamic: false, name: "s".into(), art: Arc::new(Node::path(NodeId(0), square(), Appearance::default())) });
|
||||
let xf = Affine::translate((200.0, 100.0)) * Affine::rotate(0.35);
|
||||
for box_angle in [transformed_angle(0.0, xf), 0.0] {
|
||||
let mut inst = Node::new(NodeId(3), NodeKind::SymbolInstance { symbol: "s".into(), xf });
|
||||
inst.bbox_angle = box_angle;
|
||||
let out = outline_object(Some(&d), &inst).unwrap();
|
||||
let mut angles = vec![];
|
||||
out.walk(&mut |c| angles.push(c.bbox_angle));
|
||||
assert!(angles.len() == 2 && angles.iter().all(|a| close(*a, box_angle, 1e-9)), "{box_angle}: {angles:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_to_shape_sizes() {
|
||||
let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap();
|
||||
|
||||
@@ -440,6 +440,15 @@ pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
|
||||
Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
|
||||
}
|
||||
}
|
||||
/// The whole number of 0 or more that `p` gives for `key`. A missing key or null is "missing integer
|
||||
/// `{key}`"; any other value, such as `"3"`, `2.5`, `-1` or `true`, is "`{key}` must be a whole
|
||||
/// number", as [`object_param`] words it.
|
||||
pub(crate) fn u64_req(p: &Value, key: &str, cmd: &str) -> Result<u64> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Err(bad(cmd, format!("missing integer `{key}`"))),
|
||||
Some(v) => v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))),
|
||||
}
|
||||
}
|
||||
/// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any other
|
||||
/// value, such as `"40"`, is an error: "`{key}` must be a number, not {v}".
|
||||
pub(crate) fn number_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result<f64> {
|
||||
|
||||
@@ -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; 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 (docs/mcp.md) → {changed}",
|
||||
has_selection,
|
||||
filter
|
||||
),
|
||||
@@ -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: 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 (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
|
||||
has_selection,
|
||||
nearest::apply
|
||||
),
|
||||
|
||||
@@ -713,6 +713,71 @@ fn corner_commands_refuse_bad_params() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_commands_refuse_an_unknown_item_key() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let i = id.0;
|
||||
let set = json!({"items": [{"id": i, "anchor": [0]}], "radius": 5});
|
||||
let e = s.execute("addon.pathtools.cornerSet", &set).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}");
|
||||
let set_late = json!({"items": [{"id": i, "anchors": [0]}, {"id": i, "radius": 3}], "radius": 5});
|
||||
let e = s.execute("addon.pathtools.cornerSet", &set_late).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter `items[1].radius`; the command takes: items[1].id, items[1].anchors"), "{e}");
|
||||
refuses(&mut s, "addon.pathtools.cornerSet", &[set, set_late]);
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"radius": 10})).unwrap();
|
||||
let remove = json!({"items": [{"id": i, "anchor": [0]}]});
|
||||
let e = s.execute("addon.pathtools.cornerRemove", &remove).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}");
|
||||
refuses(&mut s, "addon.pathtools.cornerRemove", &[remove]);
|
||||
assert_eq!(drawn(&s, id, 0), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_items_take_their_documented_keys() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let i = id.0;
|
||||
let r = s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i, "anchors": [0]}], "radius": 5})).unwrap();
|
||||
assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 1)), (json!(1), 5.0, 0.0));
|
||||
// A member without `anchors` takes the request's.
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i}], "anchors": [2], "radius": 6})).unwrap();
|
||||
assert_eq!(drawn(&s, id, 2), 6.0);
|
||||
let r = s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i, "anchors": [0]}]})).unwrap();
|
||||
assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 2)), (json!(1), 0.0, 6.0));
|
||||
// A null `ids` beside `items` counts as not given.
|
||||
s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i}], "ids": null})).unwrap();
|
||||
assert_eq!(drawn(&s, id, 2), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_commands_refuse_items_with_ids() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false);
|
||||
let both = json!({"items": [{"id": id.0, "anchors": [0]}], "ids": [other.0], "radius": 5});
|
||||
let e = s.execute("addon.pathtools.cornerSet", &both).unwrap_err().to_string();
|
||||
assert!(e.contains("give `items` or `ids`, not both"), "{e}");
|
||||
refuses(&mut s, "addon.pathtools.cornerSet", &[both]);
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "radius": 5})).unwrap();
|
||||
let both = json!({"items": [{"id": id.0}], "ids": [id.0]});
|
||||
let e = s.execute("addon.pathtools.cornerRemove", &both).unwrap_err().to_string();
|
||||
assert!(e.contains("give `items` or `ids`, not both"), "{e}");
|
||||
refuses(&mut s, "addon.pathtools.cornerRemove", &[both]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_corner_steps_with_older_item_keys_still_play() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false);
|
||||
let saved = json!({"items": [{"id": id.0, "anchors": [0], "old": 1}], "ids": [other.0], "radius": 5});
|
||||
s.replay("addon.pathtools.cornerSet", &saved).unwrap();
|
||||
assert_eq!((drawn(&s, id, 0), drawn(&s, id, 1)), (5.0, 0.0));
|
||||
s.replay("addon.pathtools.cornerRemove", &json!({"items": [{"id": id.0, "old": 1}]})).unwrap();
|
||||
assert_eq!(drawn(&s, id, 0), 0.0);
|
||||
}
|
||||
|
||||
/// A closed five-pointed star about (200, 150): its points at the even indices 0, 2… (the odd
|
||||
/// numbers counting from 1), its inner corners at the odd ones.
|
||||
fn star_path(s: &mut Session) -> NodeId {
|
||||
|
||||
@@ -36,7 +36,7 @@ use super::paint::paint_from;
|
||||
use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs};
|
||||
use super::{
|
||||
CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets,
|
||||
object_param, ok, paint_targets, str_param, switch_or, targets,
|
||||
object_param, ok, paint_targets, str_param, switch_or, targets, u64_req,
|
||||
};
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -604,9 +604,9 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo
|
||||
Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, read with [`index`].
|
||||
/// The whole number of 0 or more that `key` holds, read with [`u64_req`] and [`index`].
|
||||
pub(crate) fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
u64_req(p, key, cmd).map(index)
|
||||
}
|
||||
|
||||
fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Assets",
|
||||
[],
|
||||
None,
|
||||
"{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)",
|
||||
"{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64; warnings name a key in the rows or settings given that only another format takes, not one in the remembered settings). Leaves the remembered settings as they are (assets.settings.set changes them)",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
@@ -429,7 +429,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
q.insert("assets".into(), json!(all));
|
||||
}
|
||||
export_screens(s, &Value::Object(q)).map_err(|e| of_cmd(C, e))
|
||||
export_screens(s, &Value::Object(q), p).map_err(|e| of_cmd(C, e))
|
||||
}
|
||||
|
||||
fn settings_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -226,7 +226,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Break Link to Symbol",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask; a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
|
||||
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask and its own bounding box (square after Reset Bounding Box); a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
|
||||
has_selection,
|
||||
symbol_break_link
|
||||
),
|
||||
|
||||
@@ -393,8 +393,10 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The plain art of instance `inst` in document space (fresh ids), with the instance's own
|
||||
/// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice
|
||||
/// guides when it has them. The instance's opacity mask goes on the art, or on a group around it
|
||||
/// when the art has a mask of its own.
|
||||
/// guides when it has them. The art's bounding boxes turn with the instance's own box, so after
|
||||
/// Reset Bounding Box they stand as the instance's box does. The instance's opacity mask goes on
|
||||
/// the art, or on a group around it when the art has a mask of its own; a group added around the
|
||||
/// art takes the instance's box angle.
|
||||
fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None };
|
||||
let sym = d.symbols.iter().find(|s| &s.name == symbol)?;
|
||||
@@ -402,12 +404,17 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
|
||||
if let Some((color, amount)) = brush::stain(inst) {
|
||||
brush::tint_node(&mut art, &color, amount);
|
||||
}
|
||||
let art = d.place_symbol_art(symbol, &art, *xf)?;
|
||||
let mut art = d.place_symbol_art(symbol, &art, *xf)?;
|
||||
let turn = vectorcraft_doc::orient::placed_turn(inst.bbox_angle, *xf);
|
||||
if turn != 0.0 {
|
||||
art.turn_boxes(turn);
|
||||
}
|
||||
let mut art = d.reid(&art);
|
||||
if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal {
|
||||
if !matches!(art.kind, NodeKind::Group { .. }) {
|
||||
let id = d.alloc_id();
|
||||
art = Node::group(id, vec![Arc::new(art)]);
|
||||
art.bbox_angle = inst.bbox_angle;
|
||||
}
|
||||
art.opacity *= inst.opacity;
|
||||
art.blend = inst.blend;
|
||||
@@ -416,6 +423,7 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
|
||||
if art.extras().mask.is_some() {
|
||||
let id = d.alloc_id();
|
||||
art = Node::group(id, vec![Arc::new(art)]);
|
||||
art.bbox_angle = inst.bbox_angle;
|
||||
}
|
||||
art.extras_mut().mask = Some(mask.clone());
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Live Paint"],
|
||||
Some("Cmd+Alt+X"),
|
||||
"{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}",
|
||||
"{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}",
|
||||
has_selection_or_ids,
|
||||
lp_make
|
||||
),
|
||||
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Merge",
|
||||
["Object", "Live Paint"],
|
||||
None,
|
||||
"{ids?|id?} add the other selected paths (compound paths, groups of paths and Live Paint groups too; type, images and other objects are refused, nothing changes) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}",
|
||||
"{ids?|id?} add the other selected paths (compound paths, groups of paths and Live Paint groups too; type, images and other objects are refused, nothing changes) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link); a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}",
|
||||
has_multi_or_ids,
|
||||
lp_merge
|
||||
),
|
||||
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gap Options",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color; a hex color with an alpha part, #rrggbbaa or #rgba, is an error), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
|
||||
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color; a hex color with an alpha part, #rrggbbaa or #rgba, is an error), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint; when every group already has the options and closeGaps finds no gap, nothing changes (no undo step) → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
|
||||
has_selection_or_ids,
|
||||
lp_gap_options
|
||||
),
|
||||
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Bucket",
|
||||
[],
|
||||
None,
|
||||
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}",
|
||||
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces; when every face it reaches already has the paint, nothing changes (no undo step, and the recent colors stay as they are) → {group, face}",
|
||||
has_doc,
|
||||
lp_fill
|
||||
),
|
||||
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Bucket (Stroke)",
|
||||
[],
|
||||
None,
|
||||
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point → {group, edge}",
|
||||
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point; when it already has the paint and the width, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge}",
|
||||
has_doc,
|
||||
lp_stroke_edge
|
||||
),
|
||||
|
||||
@@ -52,8 +52,34 @@ fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<
|
||||
Ok((gid, nf, ne))
|
||||
}
|
||||
|
||||
/// Select `ids` alone, without an undo step, when they are not selected that way already.
|
||||
fn select_only(s: &mut Session, ids: &[NodeId]) -> Result<()> {
|
||||
let st = s.doc()?;
|
||||
let mut sel = st.selection.clone();
|
||||
sel.set(ids.iter().copied());
|
||||
if sel != st.selection {
|
||||
s.select(|_, selection| *selection = sel)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make or Merge given one Live Paint group and nothing else has nothing to add to it, so it leaves
|
||||
/// the document as it is, records no undo step and selects the group: → its reply. `None` for any
|
||||
/// other `roots`.
|
||||
fn lone_live_group(s: &mut Session, roots: &[NodeId]) -> Result<Option<Value>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let Some(&g) = roots.first().filter(|g| roots.iter().all(|r| r == *g)) else { return Ok(None) };
|
||||
let Some(n) = d.node(g).filter(|n| is_live_paint(n)) else { return Ok(None) };
|
||||
let reply = json!({ "id": g.0, "faces": b::faces(n).len(), "edges": b::edges(n).len() });
|
||||
select_only(s, &[g])?;
|
||||
Ok(Some(reply))
|
||||
}
|
||||
|
||||
pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = root_ids(s, p)?;
|
||||
if let Some(reply) = lone_live_group(s, &roots)? {
|
||||
return Ok(reply);
|
||||
}
|
||||
let (id, faces, edges) = s.edit("Make Live Paint", |d, sel| make_live(d, sel, &roots))?;
|
||||
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
|
||||
}
|
||||
@@ -130,6 +156,9 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
|
||||
|
||||
pub(super) fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = root_ids(s, p)?;
|
||||
if let Some(reply) = lone_live_group(s, &roots)? {
|
||||
return Ok(reply);
|
||||
}
|
||||
let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| merge_live(d, sel, &roots))?;
|
||||
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
|
||||
}
|
||||
@@ -230,27 +259,58 @@ pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let close = switch_or(p, "closeGaps", false, C)?;
|
||||
let targets = live_targets(s, p)?;
|
||||
let with_params = |mut options: GapOptions| {
|
||||
options.detect = detect.unwrap_or(options.detect);
|
||||
options.size = size.unwrap_or(options.size);
|
||||
options.color = color.unwrap_or(options.color);
|
||||
options
|
||||
};
|
||||
// When every group already has these options and Close Gaps With Paths has no gap to close,
|
||||
// nothing changes: no undo step, and the reply is the same.
|
||||
let unchanged = {
|
||||
let d = &s.doc()?.doc;
|
||||
let (mut faces, mut gaps, mut same) = (0, vec![], true);
|
||||
for g in &targets {
|
||||
let old = d.node(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
let options = old.gap_options();
|
||||
if with_params(options) != options {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
let open = open_gaps(old, options);
|
||||
if close && !open.is_empty() {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
faces += b::faces(old).len();
|
||||
gaps.extend(open);
|
||||
}
|
||||
same.then_some((faces, gaps))
|
||||
};
|
||||
if let Some((faces, gaps)) = unchanged {
|
||||
select_only(s, &targets)?;
|
||||
return Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": 0 }));
|
||||
}
|
||||
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
|
||||
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
|
||||
let mut changed = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let mut options = old.gap_options();
|
||||
options.detect = detect.unwrap_or(options.detect);
|
||||
options.size = size.unwrap_or(options.size);
|
||||
options.color = color.unwrap_or(options.color);
|
||||
let options = with_params(old.gap_options());
|
||||
let mut sources = source_nodes(&old);
|
||||
let mut added = 0;
|
||||
if close {
|
||||
// Close Gaps With Paths adds an unpainted line across each gap to the group's paths.
|
||||
for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) {
|
||||
let line = PathData::single(SubPath::polyline(&gap, false));
|
||||
sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default())));
|
||||
closed += 1;
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
closed += added;
|
||||
let mut n = old.clone();
|
||||
// The faces change only with what paint stops at, or with new paths.
|
||||
if close || options.max_gap() != old.gap_options().max_gap() {
|
||||
if added > 0 || options.max_gap() != old.gap_options().max_gap() {
|
||||
let (children, _, _) = build_children(d, sources, options.max_gap())?;
|
||||
if let Some(ch) = n.children_mut() {
|
||||
*ch = children;
|
||||
@@ -305,27 +365,9 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
|
||||
let group = object_param(p, "group", C)?;
|
||||
let roots = if group.is_none() { root_ids(s, p)? } else { vec![] };
|
||||
let (g, face) = s.edit("Live Paint Bucket", |d, sel| {
|
||||
let gid = match group {
|
||||
Some(g) => {
|
||||
if !d.node(g).is_some_and(is_live_paint) {
|
||||
return Err(bad(C, format!("{g} is not a Live Paint group")));
|
||||
}
|
||||
g
|
||||
}
|
||||
None => {
|
||||
let live_at = |face: fn(&Node, Point) -> Option<&Arc<Node>>| {
|
||||
roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some()))
|
||||
};
|
||||
match (live_at(b::visible_face_at), live_at(face_at)) {
|
||||
(Some(g), _) => g,
|
||||
// At a point on a hidden face of a Live Paint group among the objects, the command returns an error
|
||||
// and does not merge the objects.
|
||||
(None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())),
|
||||
(None, None) => make_live(d, sel, &roots)?.0,
|
||||
}
|
||||
}
|
||||
};
|
||||
// The faces the click or drag paints in Live Paint group `gid`: the visible face at the point,
|
||||
// and every visible face the drag crosses; → (the face at the point, the faces).
|
||||
let targets_in = |d: &Document, gid: NodeId| -> Result<(NodeId, std::collections::BTreeSet<NodeId>)> {
|
||||
let fid = d
|
||||
.node(gid)
|
||||
.and_then(|n| b::visible_face_at(n, pt))
|
||||
@@ -355,11 +397,58 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
targets.extend(map.hits_along(&chunk, None).regions.iter().filter_map(|i| face_ids.get(*i).copied()));
|
||||
}
|
||||
}
|
||||
for target in targets {
|
||||
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
|
||||
Ok((fid, targets))
|
||||
};
|
||||
// The Live Paint group the fill paints; `None` when the objects are made into one first.
|
||||
let live = {
|
||||
let d = &s.doc()?.doc;
|
||||
match group {
|
||||
Some(g) => {
|
||||
if !d.node(g).is_some_and(is_live_paint) {
|
||||
return Err(bad(C, format!("{g} is not a Live Paint group")));
|
||||
}
|
||||
Some(g)
|
||||
}
|
||||
None => {
|
||||
let live_at = |face: fn(&Node, Point) -> Option<&Arc<Node>>| {
|
||||
roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some()))
|
||||
};
|
||||
match (live_at(b::visible_face_at), live_at(face_at)) {
|
||||
(Some(g), _) => Some(g),
|
||||
// At a point on a hidden face of a Live Paint group among the objects, the command returns an error
|
||||
// and does not merge the objects.
|
||||
(None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((gid, fid))
|
||||
})?;
|
||||
};
|
||||
let (g, face) = match live {
|
||||
Some(gid) => {
|
||||
let (fid, targets) = targets_in(&s.doc()?.doc, gid)?;
|
||||
// Faces that already have the paint stay as they are: when every face has it, nothing
|
||||
// changes and no undo step is recorded.
|
||||
let d = &s.doc()?.doc;
|
||||
let unpainted: Vec<NodeId> = targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| f.appearance.fill_paint() != paint)).collect();
|
||||
if unpainted.is_empty() {
|
||||
return Ok(json!({ "group": gid.0, "face": fid.0 }));
|
||||
}
|
||||
s.edit("Live Paint Bucket", |d, _| {
|
||||
for target in unpainted {
|
||||
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
|
||||
}
|
||||
Ok((gid, fid))
|
||||
})?
|
||||
}
|
||||
None => s.edit("Live Paint Bucket", |d, sel| {
|
||||
let gid = make_live(d, sel, &roots)?.0;
|
||||
let (fid, targets) = targets_in(d, gid)?;
|
||||
for target in targets {
|
||||
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
|
||||
}
|
||||
Ok((gid, fid))
|
||||
})?,
|
||||
};
|
||||
s.remember_paint(&paint);
|
||||
Ok(json!({ "group": g.0, "face": face.0 }))
|
||||
}
|
||||
@@ -375,9 +464,22 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "width out of range"));
|
||||
}
|
||||
let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0);
|
||||
let edge = s.edit("Live Paint Bucket", |d, _| {
|
||||
let (eid, unchanged) = {
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
|
||||
let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
||||
let e = b::visible_edge_near(n, pt, tol).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
||||
// An edge that already has the paint and the width (or has no stroke and gets none) stays as
|
||||
// it is, and no undo step is recorded.
|
||||
let unchanged = match e.appearance.stroke() {
|
||||
Some(st) => st.paint == paint && st.width == width,
|
||||
None => paint.is_none(),
|
||||
};
|
||||
(e.id, unchanged)
|
||||
};
|
||||
if unchanged {
|
||||
return Ok(json!({ "group": g.0, "edge": eid.0 }));
|
||||
}
|
||||
let edge = s.edit("Live Paint Bucket", |d, _| {
|
||||
let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?;
|
||||
e.appearance.set_stroke(paint.clone());
|
||||
if let Some(st) = e.appearance.stroke_mut() {
|
||||
|
||||
@@ -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. 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 (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)
|
||||
),
|
||||
@@ -104,7 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Front to Back",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md) → {changed}",
|
||||
"{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md); no undo step when no fill changes → {changed}",
|
||||
has_selection,
|
||||
|s, _| blend(s, BlendOrder::Stack)
|
||||
),
|
||||
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Horizontally",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{} ≥3 filled objects or meshes: fills graded between the leftmost and rightmost → {changed}",
|
||||
"{} ≥3 filled objects or meshes: fills graded between the leftmost and rightmost; no undo step when no fill changes → {changed}",
|
||||
has_selection,
|
||||
|s, _| blend(s, BlendOrder::Horizontal)
|
||||
),
|
||||
@@ -122,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Vertically",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{} ≥3 filled objects or meshes: fills graded between the topmost and bottommost → {changed}",
|
||||
"{} ≥3 filled objects or meshes: fills graded between the topmost and bottommost; no undo step when no fill changes → {changed}",
|
||||
has_selection,
|
||||
|s, _| blend(s, BlendOrder::Vertical)
|
||||
),
|
||||
@@ -457,8 +457,8 @@ pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str,
|
||||
run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p, cmd)?, f, links))
|
||||
}
|
||||
|
||||
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`;
|
||||
/// errors name the command `cmd`. The reply's `notChanged` counts the locked and hidden objects the
|
||||
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`,
|
||||
/// or none when the pass changes nothing; errors name the command `cmd`. The reply's `notChanged` counts the locked and hidden objects the
|
||||
/// selection left out ([`left_out`]) that the pass would have changed.
|
||||
fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -> Result<Value> {
|
||||
let (ids, left) = match ids_param(p, "ids", cmd)? {
|
||||
@@ -471,20 +471,47 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -
|
||||
}
|
||||
};
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let (n, not_changed) = s.edit(label, |d, _| {
|
||||
let (n, not_changed) = edit_if_changed(s, label, |_, d| {
|
||||
let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden));
|
||||
let n = keeping_series_paint(d, &ids, |d| {
|
||||
let n = pass.run(d, &ids);
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok((n, not_changed))
|
||||
Ok(((n, not_changed), n > 0))
|
||||
})?;
|
||||
let mut out = json!({ "changed": n });
|
||||
not_changed.report(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`Session::edit`] for an Edit Colors pass, which may change nothing: `f` runs on a copy of the
|
||||
/// document (it gets the document as it is too) and returns its result with whether it changed the
|
||||
/// copy. Only a changed copy becomes the document, as one undo step named `label`; otherwise the
|
||||
/// undo and redo steps and the saved state stay as they were.
|
||||
pub(crate) fn edit_if_changed<T>(s: &mut Session, label: &str, f: impl FnOnce(&Document, &mut Document) -> Result<(T, bool)>) -> Result<T> {
|
||||
if s.script_running() {
|
||||
return Err(EngineError::Busy);
|
||||
}
|
||||
// A typing session ends here as `s.edit` would end it, before the copy is taken, so that the
|
||||
// copy holds what was typed.
|
||||
if !s.previewing && super::typing_in_progress(s) {
|
||||
super::settle_interaction(s)?;
|
||||
}
|
||||
let before = s.doc()?.doc.clone();
|
||||
let mut doc = (*before).clone();
|
||||
let (v, changed) = f(&before, &mut doc)?;
|
||||
// The document is shared with `before` no longer, so `s.edit` does not copy it again.
|
||||
drop(before);
|
||||
if changed {
|
||||
s.edit(label, |d, _| {
|
||||
*d = doc;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -597,11 +624,6 @@ fn pass_targets(s: &Session, p: &Value, cmd: &str) -> Result<Targets> {
|
||||
fn to_indexed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.colors.toIndexed";
|
||||
let indexing = Indexing::of(s, p)?;
|
||||
// A typing session ends here as `s.edit` would end it, before the copy is taken, so that the
|
||||
// copy holds what was typed.
|
||||
if !s.previewing && super::typing_in_progress(s) {
|
||||
super::settle_interaction(s)?;
|
||||
}
|
||||
let remake = |b: &ImageBlob| indexing.convert(b);
|
||||
let images = Scope { fill: false, stroke: false, images: true, patterns: false };
|
||||
let pass = Recolor { remake: Some(&remake), ..Recolor::new(images, &same) };
|
||||
@@ -609,22 +631,18 @@ fn to_indexed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// indexed PNG yet; it is not converted to find out. The empty PNG only gives it a new key.
|
||||
let would = |b: &ImageBlob| (!b.indexed_png() && b.pixel_size().is_some()).then(|| ImageBlob::png(vec![]));
|
||||
let (ids, left) = pass_targets(s, p, C)?;
|
||||
let st = s.doc()?;
|
||||
let colorized_ok = super::paint::check_colorized(&st.doc).is_ok();
|
||||
let not_changed = NotChanged::of(
|
||||
Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(&st.doc, &left).into_iter().map(|(_, hidden)| hidden),
|
||||
);
|
||||
let mut doc = (*st.doc).clone();
|
||||
let n = pass.run(&mut doc, &ids);
|
||||
if let Some((w, h)) = indexing.too_large.get() {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS)));
|
||||
}
|
||||
if n > 0 {
|
||||
s.edit("Convert to Indexed Color", |d, _| {
|
||||
*d = doc;
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))
|
||||
})?;
|
||||
}
|
||||
let (n, not_changed) = edit_if_changed(s, "Convert to Indexed Color", |now, doc| {
|
||||
let colorized_ok = super::paint::check_colorized(now).is_ok();
|
||||
let not_changed = NotChanged::of(
|
||||
Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(now, &left).into_iter().map(|(_, hidden)| hidden),
|
||||
);
|
||||
let n = pass.run(doc, &ids);
|
||||
if let Some((w, h)) = indexing.too_large.get() {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS)));
|
||||
}
|
||||
super::paint::check_colorized_kept(colorized_ok, doc).map_err(|e| bad(C, e))?;
|
||||
Ok(((n, not_changed), n > 0))
|
||||
})?;
|
||||
let mut out = json!({ "changed": n });
|
||||
not_changed.report(&mut out);
|
||||
Ok(out)
|
||||
@@ -923,7 +941,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?;
|
||||
Ok(changed)
|
||||
};
|
||||
let changed = s.edit(label, |d, _| keeping_series_paint(d, &ids, run))?;
|
||||
let changed = edit_if_changed(s, label, |_, d| keeping_series_paint(d, &ids, run).map(|n| (n, n > 0)))?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
|
||||
@@ -781,7 +781,10 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the first of two
|
||||
// brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type
|
||||
// as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them.
|
||||
let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect();
|
||||
let mut kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = HashMap::new();
|
||||
for b in &library {
|
||||
kinds.entry(b.name.as_str()).or_insert(&b.kind);
|
||||
}
|
||||
let brush_entries: Vec<Value> =
|
||||
brushes.iter().map(|name| json!({ "name": name, "type": kinds.get(name.as_str()).map(|k| k.type_id()) })).collect();
|
||||
let brush_rows: Vec<(String, String)> = brushes
|
||||
|
||||
@@ -231,9 +231,9 @@ pub(super) fn within_input_bound(q: Point) -> bool {
|
||||
q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, read with [`index`].
|
||||
/// The whole number of 0 or more that `key` holds, read with [`u64_req`](crate::cmd::u64_req) and [`index`].
|
||||
pub(super) fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
crate::cmd::u64_req(p, key, cmd).map(index)
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
|
||||
|
||||
@@ -11,7 +11,9 @@ use super::{
|
||||
// ---------- Pencil / Paintbrush ----------
|
||||
|
||||
/// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay
|
||||
/// corners) and simplify it to `tol`.
|
||||
/// corners) and simplify it to `tol`. A closed stroke is fitted as the open one is and closed by a
|
||||
/// straight segment from its end back to its start: fitting round the gap would curve that segment
|
||||
/// on from both ends, past them and back.
|
||||
fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
let min = (tol * 0.3).max(vectorcraft_tools::draw2::MIN_FIT_TOLERANCE * 0.3);
|
||||
let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) };
|
||||
@@ -41,15 +43,26 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
(kept[i], corner)
|
||||
})
|
||||
.collect();
|
||||
let sp = vectorcraft_tools::draw2::catmull_rom(&marked, closed);
|
||||
let sp = vectorcraft_tools::draw2::catmull_rom(&marked, false);
|
||||
if sp.anchors.len() < 3 {
|
||||
return sp;
|
||||
// Two anchors closed would run back over the line itself.
|
||||
return SubPath::new(sp.anchors, closed && n > 2);
|
||||
}
|
||||
let fitted = po::simplify_with(
|
||||
&PathData::single(sp.clone()),
|
||||
&SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false, ..Default::default() },
|
||||
);
|
||||
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
|
||||
let mut anchors = fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp).anchors;
|
||||
if closed {
|
||||
// No handle on either side of the closing segment.
|
||||
if let Some(l) = anchors.last_mut() {
|
||||
l.h_out = l.p;
|
||||
}
|
||||
if let Some(f) = anchors.first_mut() {
|
||||
f.h_in = f.p;
|
||||
}
|
||||
}
|
||||
SubPath::new(anchors, closed)
|
||||
}
|
||||
|
||||
/// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::clipboard::SwatchChoices;
|
||||
use super::{
|
||||
CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param,
|
||||
f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or,
|
||||
object_param, ok, point_param, str_param, switch_or,
|
||||
object_param, ok, point_param, str_param, switch_or, u64_req,
|
||||
};
|
||||
use crate::{Clipboard, EngineError, SourceLayer, inspect};
|
||||
|
||||
@@ -249,7 +249,10 @@ fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.activate", "missing index"))?;
|
||||
let i = match p.get("index") {
|
||||
None | Some(Value::Null) => return Err(bad("document.activate", "missing index")),
|
||||
Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.activate", "`index` must be a whole number"))?,
|
||||
};
|
||||
if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) }
|
||||
}
|
||||
|
||||
@@ -262,7 +265,10 @@ fn doc_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if from >= n {
|
||||
return Err(bad("document.move", "no such document"));
|
||||
}
|
||||
let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.move", "missing `to`: a non-negative integer"))?;
|
||||
let to = match p.get("to") {
|
||||
None | Some(Value::Null) => return Err(bad("document.move", "missing `to`: a non-negative integer")),
|
||||
Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.move", "`to` must be a whole number"))?,
|
||||
};
|
||||
let to = to.min(n.saturating_sub(1));
|
||||
if !s.move_document(from, to) {
|
||||
return Err(bad("document.move", "no such document"));
|
||||
@@ -1190,8 +1196,7 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Document ids scope node ids: two tabs may contain objects with the same node id.
|
||||
fn drop_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "edit.dropSelection";
|
||||
let key = "sourceDocument";
|
||||
let uid = p[key].as_u64().ok_or_else(|| bad(CMD, format!("missing integer `{key}`")))?;
|
||||
let uid = u64_req(p, "sourceDocument", CMD)?;
|
||||
let point = |key: &str| finite_numbers::<2>(&p[key]).ok_or_else(|| bad(CMD, format!("{key} must be [x, y] with finite coordinates")));
|
||||
let grab = point("grab")?;
|
||||
let at = point("at")?;
|
||||
|
||||
@@ -25,7 +25,7 @@ use super::dimension::EndedLink;
|
||||
use super::edit::selected_roots;
|
||||
use super::menucmds::{run_raw, squash};
|
||||
use super::object::as_clipping_path;
|
||||
use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency};
|
||||
use super::pathops::{assemble, node_path, outlinable, outline_strokes, replace_leaves, styled_copy, with_transparency};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_selection, number_or, switch_or, text_param};
|
||||
|
||||
const C: &str = "object.expand";
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines (its own fills and strokes on paths of its whole outline, which Fill and Stroke expand), live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Options",
|
||||
[],
|
||||
None,
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands (in type, on the outline Object makes of a character), which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)",
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands (in type, on the outline Object makes of a character, or of the whole type for the type object's own fills), which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let roots = selected_roots(s)?;
|
||||
@@ -189,21 +189,37 @@ impl Expandable {
|
||||
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
|
||||
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
|
||||
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
|
||||
// Type's outlines take its characters' stroke and fill, and a path of its whole outline
|
||||
// takes the type object's own fills and strokes (see `type.createOutlines`).
|
||||
// Any stroke Outline Stroke outlines, not only the topmost one.
|
||||
let own_stroke = c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st)));
|
||||
let own_pattern = c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some());
|
||||
let own_gradient = c.appearance.items.iter().any(|i| gradient_fill(i).is_some());
|
||||
// Type without an outline (empty, or only spaces) gets no path for its own fills and strokes.
|
||||
let whole = match &c.kind {
|
||||
NodeKind::Text(t) if own_stroke || own_pattern || own_gradient => text_whole_outline(t),
|
||||
_ => None,
|
||||
};
|
||||
e.stroke |= match &c.kind {
|
||||
// Type's outlines take its characters' stroke (see `type.createOutlines`).
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()),
|
||||
_ => shape && !c.appearance.stroke_paint().is_none(),
|
||||
NodeKind::Text(t) => (own_stroke && whole.is_some()) || t.runs.iter().any(|r| r.style.has_stroke()),
|
||||
_ => shape && own_stroke,
|
||||
};
|
||||
// Once one is found, the next path is not measured.
|
||||
e.gradient = e.gradient
|
||||
|| match &c.kind {
|
||||
NodeKind::Text(t) => text_gradient_expands(d, t),
|
||||
NodeKind::Text(t) => {
|
||||
text_gradient_expands(d, t)
|
||||
|| (own_gradient && whole.as_ref().is_some_and(|(path, tb)| text_object_gradient_expands(d, c, path, *tb)))
|
||||
}
|
||||
_ => shape && gradient_expands(d, c),
|
||||
};
|
||||
e.fill |= e.gradient;
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())),
|
||||
_ => shape && c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()),
|
||||
NodeKind::Text(t) => {
|
||||
(own_pattern && whole.is_some())
|
||||
|| t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some()))
|
||||
}
|
||||
_ => shape && own_pattern,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -275,6 +291,33 @@ fn text_gradient_expands(d: &Document, t: &TextObject) -> bool {
|
||||
|| vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t).iter().any(|(run, bar)| expands(*run, bar))
|
||||
}
|
||||
|
||||
/// The path of type `t`'s whole outline in the document (glyphs, ruby glyphs and underline and
|
||||
/// strikethrough bars), which `type.createOutlines` paints with the type object's own fills and
|
||||
/// strokes, and the layout's bounds there; `None` when the type has no outline.
|
||||
fn text_whole_outline(t: &TextObject) -> Option<(PathData, Rect)> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let lay = vectorcraft_text::layout(db, t);
|
||||
let mut all = BezPath::new();
|
||||
for g in lay.glyphs.iter().chain(&lay.ruby) {
|
||||
all.extend(g.outline.iter());
|
||||
}
|
||||
for (_, bar) in vectorcraft_text::decorations(&lay, db, t) {
|
||||
all.extend(bar.iter());
|
||||
}
|
||||
let path = PathData::from_bezpath(&all).transformed(t.xf);
|
||||
(!path.is_empty()).then(|| (path, t.xf.transform_rect_bbox(lay.bounds)))
|
||||
}
|
||||
|
||||
/// Whether type node `n` has a gradient fill of its own (not its characters') that Fill expands
|
||||
/// once Object has outlined it: [`gradient_expands`] on `path`, the type's whole outline
|
||||
/// ([`text_whole_outline`]), with its unplaced gradients fixed to `tb`, the layout's bounds in the
|
||||
/// document. Leaves the document as it is.
|
||||
fn text_object_gradient_expands(d: &Document, n: &Node, path: &PathData, tb: Rect) -> bool {
|
||||
let mut appearance = Appearance { items: n.appearance.items.clone(), ..Default::default() };
|
||||
appearance.pin_gradients(tb);
|
||||
gradient_expands(d, &Node::path(NodeId(u64::MAX), path.clone(), appearance))
|
||||
}
|
||||
|
||||
/// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]).
|
||||
fn piece_gradient_expands(d: &Document, l: &Node) -> bool {
|
||||
if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) {
|
||||
|
||||
@@ -92,7 +92,7 @@ pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
pub(crate) use save::save as save_document;
|
||||
pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder,
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, file_reads,
|
||||
leave_out_unreadable_save_paths, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan,
|
||||
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused. Under automation read roots, an svg or svgz row is refused, before the folder is made or any file is written, while the document links a JavaScript file outside them, as document.export refuses it",
|
||||
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused; a key only another format takes in a row or in settings is not used, and warnings name it as given (formats[0].quality; a key in settings only when no row of that format reads it; not a key of an exportPreset). Under automation read roots, an svg or svgz row is refused, before the folder is made or any file is written, while the document links a JavaScript file outside them, as document.export refuses it",
|
||||
has_doc,
|
||||
screens::export_for_screens
|
||||
),
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"DXF Info",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name?, dataBase64} what a DXF drawing holds → {version (2018, R12…), units (its drawing unit: Millimeters, Inches, Unitless…), layouts: [\"Model\", paper layouts…], layers: [name…], unit, scale (the default ratio: 1 unit = scale drawing units, the drawing at 1:1)}; a binary DXF or a DWG answers an error saying to save it as ASCII DXF",
|
||||
"{path} or {name?, dataBase64} what a DXF drawing holds → {version (2018, R12…), units (its drawing unit: Millimeters, Inches, Unitless…), layouts: [\"Model\", paper layouts…], layers: [name…], unit, scale (the default ratio: 1 unit = scale drawing units, the drawing at 1:1)}; a DWG answers an error saying to save it as DXF",
|
||||
always,
|
||||
dxf_info
|
||||
)]
|
||||
|
||||
@@ -32,26 +32,25 @@ pub fn unused_option_notes(cmd: &str, f: &Format, p: &Value) -> Vec<String> {
|
||||
}
|
||||
let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k);
|
||||
let written = super::save::written_as(cmd, f, p);
|
||||
let legacy = f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false);
|
||||
let reads = |k: &str| match (f.id, k) {
|
||||
// A legacy `.ai` file is not a PDF: it reads its own options only.
|
||||
("ai", _) if legacy => super::legacy_ai::reads(k),
|
||||
// The other `.ai` files don't read the legacy file's options, and `preserveEditing` is
|
||||
// on for them whatever is given.
|
||||
("ai", "preserveEditing") => false,
|
||||
("ai", _) if super::legacy_ai::LEGACY_ONLY.contains(&k) => false,
|
||||
// An export writes one file: only a save writes each artboard to a file of its own, and
|
||||
// a native file's `range` names those files.
|
||||
("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false,
|
||||
_ => super::save::reads_option(f, k),
|
||||
};
|
||||
let legacy = super::save::legacy_file(cmd, f, p);
|
||||
given
|
||||
.iter()
|
||||
.filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !reads(k))
|
||||
.filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !export_reads(f, legacy, k))
|
||||
.map(|(k, _)| super::save::unused_option_note(k, &written))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Does the file an export writes in `f` read option `key`, `legacy` when it is a legacy `.ai`
|
||||
/// file? As a save's file does ([`super::save::file_reads`]), except that an export writes one
|
||||
/// file: only a save writes each artboard to a file of its own, and a native file's `range` names
|
||||
/// those files.
|
||||
pub(super) fn export_reads(f: &Format, legacy: bool, key: &str) -> bool {
|
||||
match (f.id, key) {
|
||||
("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false,
|
||||
_ => super::save::file_reads(f, legacy, key),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable("document.serialize", Some(text_param(p, "format", "document.serialize")?.unwrap_or("vectorcraft")), None)?;
|
||||
check_linked_scripts("document.serialize", f.id, s.doc()?)?;
|
||||
|
||||
@@ -95,14 +95,18 @@ pub struct SavePlan {
|
||||
/// The `modified` param (as `date_param` reads it): the File Info date a Save or Save As to a file
|
||||
/// stamps; `None`: the time it is written.
|
||||
pub modified: Option<Option<i64>>,
|
||||
/// Warnings about the params themselves: each native option (`compress`, `version`,
|
||||
/// `preview`, `precision`) given beside the path, and each key of `options`, that the format
|
||||
/// doesn't read, naming the file the save writes.
|
||||
/// Warnings about the params themselves: each option a save format takes given beside the
|
||||
/// path, and each key of `options`, that the file the save writes doesn't read
|
||||
/// ([`file_reads`]), naming that file.
|
||||
pub notes: Vec<String>,
|
||||
/// The file the document was read from and what reading it left out, when the save needs
|
||||
/// `acknowledgeLoss` to write over it and was not given it: every file the save writes is
|
||||
/// checked against it before the first is written.
|
||||
pub lossy_source: Option<(String, Vec<String>)>,
|
||||
/// The `.ai` options the file doesn't read, with the values the document remembers from its
|
||||
/// last `.ai` save: not in `options`, they go back into the remembered options once the file
|
||||
/// is the document's own.
|
||||
pub kept: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl SavePlan {
|
||||
@@ -279,7 +283,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
}
|
||||
}
|
||||
// None given: the ones the document was last saved with in this format.
|
||||
let mut options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given };
|
||||
let from_memory = given.is_empty() && format.id == st.format;
|
||||
let mut options = if from_memory { remembered_save_options(st) } else { given };
|
||||
// Remember the effective choice, including the encoder's default, so another document's
|
||||
// later save cannot change what this one writes. Invalid non-null values still reach validation.
|
||||
if matches!(format.id, "vectorcraft" | "template") {
|
||||
@@ -311,21 +316,54 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
let given = str_param(p, "path").is_some_and(|g| !g.is_empty());
|
||||
super::check_not_lossy_overwrite(st, path, p, cmd).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })?;
|
||||
}
|
||||
let written = written_as(cmd, format, &Value::Object(options.clone()));
|
||||
let top = NATIVE_OPTIONS.iter().filter(|k| p.get(**k).is_some_and(|v| !v.is_null())).map(|k| k.to_string());
|
||||
let chosen = Value::Object(options.clone());
|
||||
let written = written_as(cmd, format, &chosen);
|
||||
let legacy = legacy_file(cmd, format, &chosen);
|
||||
// Beside the path, a key some save format takes; in `options`, any key.
|
||||
let top = p.as_object().into_iter().flatten().filter(|(k, v)| !v.is_null() && save_listed(k)).map(|(k, _)| k.to_string());
|
||||
let nested = p.get("options").and_then(Value::as_object).into_iter().flatten().filter(|(_, v)| !v.is_null()).map(|(k, _)| k);
|
||||
let notes = top
|
||||
.filter(|k| !reads_option(format, k))
|
||||
.chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}")))
|
||||
.filter(|k| !file_reads(format, legacy, k))
|
||||
.chain(nested.filter(|k| !file_reads(format, legacy, k)).map(|k| format!("options.{k}")))
|
||||
.map(|k| unused_option_note(&k, &written))
|
||||
.collect();
|
||||
// When the document's own file is a `.ai`, the `.ai` options this file doesn't read and the
|
||||
// request doesn't give keep their remembered values ([`SavePlan::kept`]), so that Save Options
|
||||
// still shows them (it sends only the ones the file reads). They are neither written nor sent
|
||||
// again.
|
||||
let mut kept = Map::new();
|
||||
if format.id == "ai" && format.id == st.format {
|
||||
let last = remembered_save_options(st);
|
||||
for o in format.options.iter().filter(|o| !file_reads(format, legacy, o.name)) {
|
||||
let value = if from_memory {
|
||||
options.remove(o.name)
|
||||
} else if options.contains_key(o.name) {
|
||||
None
|
||||
} else {
|
||||
last.get(o.name).cloned()
|
||||
};
|
||||
if let Some(v) = value {
|
||||
kept.insert(o.name.to_string(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let lossy_source = super::lossy_source(st, p);
|
||||
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source })
|
||||
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source, kept })
|
||||
}
|
||||
|
||||
/// The native save options, which a save also takes beside the path.
|
||||
const NATIVE_OPTIONS: [&str; 4] = ["compress", "version", "preview", "precision"];
|
||||
|
||||
/// Does some save format (or template format) take option `key`?
|
||||
fn save_listed(key: &str) -> bool {
|
||||
NATIVE_OPTIONS.contains(&key)
|
||||
|| super::FORMATS
|
||||
.iter()
|
||||
.filter(|f| SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id))
|
||||
.flat_map(|f| f.options)
|
||||
.any(|o| o.name == key)
|
||||
}
|
||||
|
||||
/// The first option `p` gives both beside the path and in `options` with different values, as
|
||||
/// `(key, top-level value, options value)`. For an SVG save (`svg`), an SVG option's value on
|
||||
/// either side is the one its `svg: {…}` object gives, else the flat one.
|
||||
@@ -467,6 +505,26 @@ pub fn reads_option(f: &Format, key: &str) -> bool {
|
||||
f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key)))
|
||||
}
|
||||
|
||||
/// Does the file a save or an export writes in `f` read option `key`, `legacy` when it is a legacy
|
||||
/// `.ai` file ([`super::legacy_ai::wanted`])? As [`reads_option`], except for `.ai` and `.ait`
|
||||
/// files: a legacy file reads its own options only ([`super::legacy_ai::reads`]), and the others
|
||||
/// don't read the legacy file's options or `preserveEditing`, which is on for them whatever is
|
||||
/// given.
|
||||
pub fn file_reads(f: &Format, legacy: bool, key: &str) -> bool {
|
||||
match (f.id, key) {
|
||||
("ai", _) if legacy => super::legacy_ai::reads(key),
|
||||
("ai" | "ait", "preserveEditing") => false,
|
||||
("ai" | "ait", _) if super::legacy_ai::LEGACY_ONLY.contains(&key) => false,
|
||||
_ => reads_option(f, key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a save or an export of `cmd` in `f` with options `p` to write a legacy `.ai` file? An
|
||||
/// unknown version is not one (the save or export refuses it).
|
||||
pub(super) fn legacy_file(cmd: &str, f: &Format, p: &Value) -> bool {
|
||||
f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The note a save or an export gives for option `key`, as the request names it, which the file it
|
||||
/// writes (`written`, see [`written_as`]) doesn't read.
|
||||
pub(super) fn unused_option_note(key: &str, written: &Format) -> String {
|
||||
@@ -1037,6 +1095,7 @@ impl SaveJob {
|
||||
st.path = Some(path);
|
||||
st.format = self.plan.format.id;
|
||||
st.save_options = self.plan.options;
|
||||
st.save_options.extend(self.plan.kept);
|
||||
// A later save rounds only when it gives `precision` too.
|
||||
st.save_options.remove("precision");
|
||||
st.save_artboards = self.snapshot.artboards.iter().map(|a| a.id).collect();
|
||||
@@ -1239,7 +1298,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them; an svg or svgz save (a document whose own format is SVG or SVGZ included) is refused, before any file is written, while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), also by file.saveAs and file.saveCopy. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings name an option given flat or in options that the file doesn't read: a legacy .ai reads only its own options, and other .ai files don't read outlineText, hiddenObjects, transparentImages or preserveEditing), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them; an svg or svgz save (a document whose own format is SVG or SVGZ included) is refused, before any file is written, while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), also by file.saveAs and file.saveCopy. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -18,7 +18,7 @@ use vectorcraft_geom::Rect;
|
||||
|
||||
use super::super::{EngineError, NodeId, Result, Session, bad, bool_or, str_param};
|
||||
use super::encode::{add_note, single_artboard};
|
||||
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
|
||||
use super::export::{export_crop, export_reads, isolated, selected_artwork, without_artboards};
|
||||
use super::naming::{self, NameParts};
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, Note, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_notes, merge,
|
||||
@@ -524,6 +524,77 @@ fn rows_of(s: &Session, p: &Value, preset: Option<&str>) -> Result<Vec<Row>> {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// A note for each option of a format row (`formats`) or of a format's `settings` in the request
|
||||
/// `asked` that some format takes but the file the row writes, or every row of that format writes,
|
||||
/// doesn't read (`{format: png, quality: 80}`), in the wording of a save's note and named as the
|
||||
/// request names it (`formats[0].quality`, `settings.png.quality`). The rows and settings the
|
||||
/// export writes with are those of `p` (`asked` with a preset or the remembered settings laid
|
||||
/// under it). A row's own keys (`scale`, `suffix`…), the keys the export reads for every format
|
||||
/// (`includeArtboardFill`, `richBlack`, `params`) and an artboard choice (the export picks the
|
||||
/// artboards) get none, and so does an exporter plug-in's format.
|
||||
pub(crate) fn unused_option_notes(asked: &Value, p: &Value) -> Vec<String> {
|
||||
let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k);
|
||||
let own = |k: &str| {
|
||||
ROW_KEYS.contains(&k) || ARTBOARD_PARAMS.contains(&k) || matches!(k, "useArtboards" | "includeArtboardFill" | "richBlack" | "params")
|
||||
};
|
||||
// The keys of `given` that `f`, writing with `options` (the format's settings and the row's
|
||||
// own), doesn't read.
|
||||
let unread = |f: &Format, given: &Map<String, Value>, options: &Value| -> Vec<String> {
|
||||
if vectorcraft_plugins::export::by_format(f.id).is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let legacy = super::save::legacy_file(C, f, options);
|
||||
given.iter().filter(|(k, v)| !v.is_null() && !own(k) && listed(k) && !export_reads(f, legacy, k)).map(|(k, _)| k.clone()).collect()
|
||||
};
|
||||
let note = |key: String, f: &Format, options: &Value| super::save::unused_option_note(&key, &super::save::written_as(C, f, options));
|
||||
let settings = p.get("settings").and_then(Value::as_object);
|
||||
let of_format = |id: &str| settings.and_then(|s| s.get(id)).and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
// A row's format and the options it writes with.
|
||||
let row_of = |row: &Value| -> Option<(&'static Format, Value)> {
|
||||
let (Some(row), Ok(f)) = (row.as_object(), writable(C, Some(str_param(row, "format").unwrap_or("png")), None)) else { return None };
|
||||
let mut options = of_format(f.id);
|
||||
options.extend(row.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
Some((f, Value::Object(options)))
|
||||
};
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
if let Some(Value::Array(rows)) = asked.get("formats") {
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let (Some((f, options)), Some(given)) = (row_of(row), row.as_object()) else { continue };
|
||||
out.extend(unread(f, given, &options).into_iter().map(|k| note(format!("formats[{i}].{k}"), f, &options)));
|
||||
}
|
||||
}
|
||||
// The rows written, each with its own keys: a format's settings are read as each of its rows
|
||||
// writes them (a row may name another `.ai` version), else as they are.
|
||||
let written: Vec<(&Format, Value, Map<String, Value>)> = match p.get("formats") {
|
||||
Some(Value::Array(rows)) => {
|
||||
rows.iter().filter_map(|row| row_of(row).map(|(f, options)| (f, options, row.as_object().cloned().unwrap_or_default()))).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for (id, given) in asked.get("settings").and_then(Value::as_object).into_iter().flatten() {
|
||||
let (Some(given), Ok(f)) = (given.as_object(), writable(C, Some(id), None)) else { continue };
|
||||
let mut rows: Vec<(&Value, Option<&Map<String, Value>>)> =
|
||||
written.iter().filter(|(g, ..)| g.id == f.id).map(|(_, o, row)| (o, Some(row))).collect();
|
||||
let alone = Value::Object(of_format(f.id));
|
||||
if rows.is_empty() {
|
||||
rows.push((&alone, None));
|
||||
}
|
||||
let unread_by: Vec<Vec<String>> = rows.iter().map(|(o, _)| unread(f, given, o)).collect();
|
||||
// A setting a row reads and doesn't give itself is used. One no row uses is noted when a
|
||||
// row doesn't read it, naming the first such row's file.
|
||||
for (k, v) in given {
|
||||
// A row's own key, null too, replaces the setting.
|
||||
let overridden = |row: Option<&Map<String, Value>>| row.is_some_and(|r| r.contains_key(k));
|
||||
let used = rows.iter().zip(&unread_by).any(|((_, row), u)| !u.contains(k) && !overridden(*row));
|
||||
let unread_row = rows.iter().zip(&unread_by).find(|(_, u)| u.contains(k));
|
||||
if let (false, false, Some(((options, _), _))) = (v.is_null(), used, unread_row) {
|
||||
out.push(note(format!("settings.{id}.{k}"), f, options));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Check the export settings in `p` (formats, preset, settings…) as an export would read them.
|
||||
pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
|
||||
rows_of(s, p, ScreenParams::of(p)?.preset.as_deref()).map(|_| ())
|
||||
@@ -646,9 +717,10 @@ impl Output {
|
||||
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A folder the client gave, as against the one its `exportPreset` keeps.
|
||||
let given = p.get("folder").is_some();
|
||||
let asked = p;
|
||||
let p = with_preset(s, p)?;
|
||||
let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) };
|
||||
let mut r = export(s, &p).map_err(|e| match hidden {
|
||||
let mut r = export(s, &p, asked).map_err(|e| match hidden {
|
||||
Some(folder) => super::access::naming_in_error(e, folder),
|
||||
None => e,
|
||||
})?;
|
||||
@@ -1022,9 +1094,12 @@ pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map<Stri
|
||||
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
|
||||
/// so do different files that would share a name, with or without the `name` pattern. The reply's
|
||||
/// `warnings` lists every warning the encoders give for the files, each once, what the files lose
|
||||
/// first ([`super::note_texts`]).
|
||||
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// first ([`super::note_texts`]), and a note for each option of the request `asked` (`p` before a
|
||||
/// preset or the remembered settings were laid under it) that the files the rows write don't read
|
||||
/// ([`unused_option_notes`]).
|
||||
pub(crate) fn export(s: &mut Session, p: &Value, asked: &Value) -> Result<Value> {
|
||||
check_passwords(p)?;
|
||||
let unused = unused_option_notes(asked, p);
|
||||
let o = ScreenParams::of(p)?;
|
||||
let rows = rows_of(s, p, o.preset.as_deref())?;
|
||||
o.one_source()?;
|
||||
@@ -1054,7 +1129,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Include Artboard Fill off: the encoders leave the background colors out.
|
||||
let fill = if o.include_artboard_fill == Some(false) { json!({ "includeArtboardFill": false }) } else { json!({}) };
|
||||
let rows = rows.into_iter().map(|r| Row { options: merge(with_black_output(s, &r.options), fill.clone()), ..r }).collect();
|
||||
let export = ScreenExport { cmd: C, o, rows, naming, items, hidden: None };
|
||||
let export = ScreenExport { cmd: C, o, rows, naming, items, unused, hidden: None };
|
||||
match &mut s.screens_hand_off {
|
||||
Some(slot) => {
|
||||
*slot = Some(export);
|
||||
@@ -1075,6 +1150,8 @@ pub struct ScreenExport {
|
||||
rows: Vec<Row>,
|
||||
naming: Naming,
|
||||
items: Vec<Item>,
|
||||
/// The notes on options the files don't read ([`unused_option_notes`]).
|
||||
unused: Vec<String>,
|
||||
/// The export folder and the name the request that prepared the export may see it by, when the
|
||||
/// folder came from the `exportPreset` and is outside that request's read roots: the reply and
|
||||
/// the errors of [`ScreenExport::write`] name it as [`export_for_screens`] does.
|
||||
@@ -1139,14 +1216,17 @@ impl ScreenExport {
|
||||
|
||||
/// [`ScreenExport::write`].
|
||||
fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
let ScreenExport { o, rows, naming, items, .. } = export;
|
||||
let ScreenExport { o, rows, naming, items, unused, .. } = export;
|
||||
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
|
||||
if let Some(dir) = folder {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
let mut out = Output::default();
|
||||
// The encoders' notes for every file, each once.
|
||||
// The notes on the options, then the encoders' notes for every file, each once.
|
||||
let mut notes: Vec<Note> = Vec::new();
|
||||
for text in unused {
|
||||
add_note(&mut notes, Note::setting(text));
|
||||
}
|
||||
// Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are
|
||||
// written on a page of their own bounds, which it doesn't grow.
|
||||
let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none();
|
||||
|
||||
@@ -748,8 +748,8 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
|
||||
}
|
||||
|
||||
/// An option given beside the path (an SVG option flat or in `svg`) and in `options` with different
|
||||
/// values is refused before anything is written; `compress`, `version`, `preview` or `precision` beside the path of a format
|
||||
/// that doesn't read it is named in the warnings.
|
||||
/// values is refused before anything is written; a native option beside the path of a format that doesn't read it is named
|
||||
/// in the warnings.
|
||||
#[test]
|
||||
fn a_save_refuses_disagreeing_options_and_names_unread_native_ones() {
|
||||
let d = tmp_dir("save-options");
|
||||
|
||||
@@ -163,12 +163,12 @@ fn dxf_info_lists_layouts_layers_and_units() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_dxf_says_to_save_it_as_ascii() {
|
||||
fn damaged_binary_dxf_says_so() {
|
||||
let mut s = Session::new();
|
||||
let mut bytes = vectorcraft_cad::import::BINARY_SENTINEL.to_vec();
|
||||
bytes.extend([0u8; 64]);
|
||||
let e = open(&mut s, &bytes, json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}");
|
||||
assert!(e.contains("binary DXF") && e.contains("damaged"), "{e}");
|
||||
let e = s.execute("document.dxfInfo", &json!({"dataBase64": b64(&bytes)})).unwrap_err().to_string();
|
||||
assert!(e.contains("binary DXF"), "{e}");
|
||||
}
|
||||
|
||||
@@ -280,3 +280,102 @@ fn note_texts_give_the_losses_first_each_kind_in_order() {
|
||||
assert_eq!(note_texts(&[Note::setting("s1"), Note::setting("s2")]), ["s1", "s2"]);
|
||||
assert!(note_texts(&[]).is_empty());
|
||||
}
|
||||
|
||||
/// A save notes the `.ai` options its file doesn't read as an export does: a PDF-compatible file
|
||||
/// doesn't read the legacy file's options or `preserveEditing`, and a legacy file reads its own
|
||||
/// options only. The notes cover the options beside the path and in `options`.
|
||||
#[test]
|
||||
fn a_save_notes_the_ai_options_its_file_does_not_read() {
|
||||
let (mut s, notes) = (with_art(), unused);
|
||||
let r = s
|
||||
.execute("file.saveCopy", &json!({"format": "ai", "outlineText": true, "preserveEditing": false, "options": {"hiddenObjects": "omit"}}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
notes(&r),
|
||||
[
|
||||
"`outlineText` was not used: PDF-compatible .ai files don't read it",
|
||||
"`preserveEditing` was not used: PDF-compatible .ai files don't read it",
|
||||
"`options.hiddenObjects` was not used: PDF-compatible .ai files don't read it"
|
||||
],
|
||||
"{r}"
|
||||
);
|
||||
let p = json!({"format": "ai", "aiVersion": "7", "compress": true, "marks": {"trim": true}, "options": {"pdfCompatible": false}});
|
||||
let r = s.execute("file.saveCopy", &p).unwrap();
|
||||
assert_eq!(
|
||||
notes(&r),
|
||||
[
|
||||
"`compress` was not used: legacy .ai (version 7) files don't read it",
|
||||
"`marks` was not used: legacy .ai (version 7) files don't read it",
|
||||
"`options.pdfCompatible` was not used: legacy .ai (version 7) files don't read it"
|
||||
],
|
||||
"{r}"
|
||||
);
|
||||
// The file's own options get none.
|
||||
for p in [
|
||||
json!({"format": "ai", "aiVersion": "7", "outlineText": true, "options": {"hiddenObjects": "omit", "range": "1"}}),
|
||||
json!({"format": "ai", "compress": true, "marks": {"trim": true}, "options": {"editingData": false}}),
|
||||
json!({"format": "pdf", "marks": {"trim": true}, "preserveEditing": false}),
|
||||
] {
|
||||
let r = s.execute("file.saveCopy", &p).unwrap();
|
||||
assert!(notes(&r).is_empty(), "{p}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A save notes an option beside the path that only another save format takes, as it notes one in
|
||||
/// `options`.
|
||||
#[test]
|
||||
fn a_save_notes_an_option_beside_the_path_that_only_another_save_format_takes() {
|
||||
let (mut s, notes) = (with_art(), unused);
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "vectorcraft", "marks": {"trim": true}, "svg": {"minify": true}})).unwrap();
|
||||
assert_eq!(notes(&r), ["`marks` was not used: VectorCraft files don't read it", "`svg` was not used: VectorCraft files don't read it"], "{r}");
|
||||
}
|
||||
|
||||
/// The `.ai` options a save's file doesn't read keep the values the document's last save in that
|
||||
/// format gave them, so a save from Save Options, which sends only the options the file reads,
|
||||
/// leaves the other version's choices as they were.
|
||||
#[test]
|
||||
fn a_save_keeps_the_ai_options_its_file_does_not_read_from_the_last_save() {
|
||||
let mut s = with_art();
|
||||
let dir = tmp_dir("ai-kept");
|
||||
let path = dir.join("kept.ai").to_string_lossy().to_string();
|
||||
let value = |s: &mut Session, key: &str| s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap()["options"][key]["value"].clone();
|
||||
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "outlineText": true}})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "pdf", "editingData": false}})).unwrap();
|
||||
assert_eq!(value(&mut s, "outlineText"), json!(true));
|
||||
assert_eq!(value(&mut s, "editingData"), json!(false));
|
||||
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "hiddenObjects": "omit"}})).unwrap();
|
||||
assert_eq!(value(&mut s, "editingData"), json!(false));
|
||||
// An option the request gives is the one saved.
|
||||
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "outlineText": false}})).unwrap();
|
||||
assert_eq!(value(&mut s, "outlineText"), json!(false));
|
||||
// The kept options are neither written nor sent again: a plain save's options (which a retry
|
||||
// in another folder sends to Save As) hold only what the legacy file reads.
|
||||
for p in [json!({}), json!({"options": {"aiVersion": "7"}})] {
|
||||
let plan = save_plan(&s, SaveMode::Save, &p).unwrap();
|
||||
assert!(!plan.options.contains_key("editingData") && !plan.options.contains_key("compress"), "{:?}", plan.options);
|
||||
assert_eq!(plan.kept.get("editingData"), Some(&json!(false)));
|
||||
assert!(plan.notes.is_empty(), "{:?}", plan.notes);
|
||||
let retry = json!({"path": path, "format": "ai", "options": plan.options});
|
||||
let r = s.execute("file.saveAs", &retry).unwrap();
|
||||
assert!(!r["warnings"].to_string().contains("was not used"), "{r}");
|
||||
assert_eq!(value(&mut s, "editingData"), json!(false));
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// The first `.ai` save of a document whose own file is another format remembers only the options
|
||||
/// it was given: nothing from that format's options is kept.
|
||||
#[test]
|
||||
fn a_first_ai_save_keeps_only_the_options_it_is_given() {
|
||||
let mut s = with_art();
|
||||
let dir = tmp_dir("ai-first");
|
||||
let native = dir.join("first.vectorcraft").to_string_lossy().to_string();
|
||||
let path = dir.join("first.ai").to_string_lossy().to_string();
|
||||
s.execute("file.saveAs", &json!({"path": native, "options": {"compress": false}})).unwrap();
|
||||
let plan = save_plan(&s, SaveMode::SaveAs, &json!({"path": path, "format": "ai", "options": {"aiVersion": "7"}})).unwrap();
|
||||
assert!(plan.kept.is_empty(), "{:?}", plan.kept);
|
||||
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7"}})).unwrap();
|
||||
let o = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
|
||||
assert_eq!(o["options"]["compress"]["value"], json!(true), "{o}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -1163,3 +1163,93 @@ fn single_file_pdfs_hold_every_chosen_artboard() {
|
||||
assert_eq!(names(&r), ["Artboard-1.pdf", "Artboard-2.pdf"]);
|
||||
assert!(s.execute("document.exportForScreens", &json!({"pdfs": "both", "formats": [{"format": "pdf"}]})).is_err());
|
||||
}
|
||||
|
||||
/// The warnings of reply `r` that name an option the file doesn't read.
|
||||
fn unused(r: &Value) -> Vec<String> {
|
||||
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// An option of a format row, or of a format's settings, that only another format reads is
|
||||
/// accepted, and the reply's warnings name it as the request does, in the words of a save's note.
|
||||
#[test]
|
||||
fn a_row_option_only_another_format_reads_gets_a_note() {
|
||||
let mut s = session(1);
|
||||
let p = json!({
|
||||
"formats": [{"format": "png", "quality": 80}, {"format": "jpg", "quality": 80}, {"format": "ai", "aiVersion": "7", "compress": true}],
|
||||
"settings": {"png": {"quality": 50, "interlaced": true}, "svg": {"interlaced": true}},
|
||||
});
|
||||
let r = export(&mut s, p);
|
||||
assert_eq!(
|
||||
unused(&r),
|
||||
[
|
||||
"`formats[0].quality` was not used: PNG files don't read it",
|
||||
"`formats[2].compress` was not used: legacy .ai (version 7) files don't read it",
|
||||
"`settings.png.quality` was not used: PNG files don't read it",
|
||||
"`settings.svg.interlaced` was not used: SVG files don't read it"
|
||||
],
|
||||
"{r}"
|
||||
);
|
||||
// A row's own keys, the keys every row takes and the artboard choices get none.
|
||||
let p = json!({"formats": [
|
||||
{"format": "png", "scale": "2x", "suffix": "@2x", "folder": "x", "includeArtboardFill": false, "artboard": 0, "useArtboards": true},
|
||||
{"format": "pdf", "marks": {"trim": true}, "preset": "High Quality Print"},
|
||||
{"format": "vectorcraft", "compress": true, "range": "1"},
|
||||
]});
|
||||
let r = export(&mut s, p);
|
||||
assert!(unused(&r).is_empty(), "{r}");
|
||||
// A format's settings are read as each row of that format writes them: a row can name another
|
||||
// `.ai` version than the settings do.
|
||||
let r = export(&mut s, json!({"formats": [{"format": "ai", "aiVersion": "7"}], "settings": {"ai": {"marks": {"trim": true}}}}));
|
||||
assert_eq!(unused(&r), ["`settings.ai.marks` was not used: legacy .ai (version 7) files don't read it"], "{r}");
|
||||
let r = export(&mut s, json!({"formats": [{"format": "ai", "aiVersion": "pdf"}], "settings": {"ai": {"aiVersion": "7", "compress": true}}}));
|
||||
assert!(unused(&r).is_empty(), "{r}");
|
||||
// A setting another row of the format reads gets none; one no row reads names the first row's
|
||||
// file.
|
||||
let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai"}]);
|
||||
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
|
||||
assert!(unused(&r).is_empty(), "{r}");
|
||||
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"quality": 80}}}));
|
||||
assert_eq!(unused(&r), ["`settings.ai.quality` was not used: legacy .ai (version 7) files don't read it"], "{r}");
|
||||
// A row that gives the key itself doesn't use the setting.
|
||||
let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai", "editingData": true}]);
|
||||
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
|
||||
assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
|
||||
let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai", "editingData": null}]);
|
||||
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
|
||||
assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
|
||||
// An export writes one file per row and item: a native row doesn't read `separateArtboards`.
|
||||
let r = export(&mut s, json!({"formats": [{"format": "vectorcraft", "separateArtboards": true}]}));
|
||||
assert_eq!(unused(&r), ["`formats[0].separateArtboards` was not used: VectorCraft files don't read it"], "{r}");
|
||||
}
|
||||
|
||||
/// The Asset Export panel's export notes the options of its request, not those of the settings
|
||||
/// the document remembers.
|
||||
#[test]
|
||||
fn an_asset_export_notes_the_options_it_is_given() {
|
||||
let mut s = session(1);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("assets.add", &json!({"multiple": false})).unwrap();
|
||||
s.execute("assets.settings.set", &json!({"formats": [{"format": "png", "quality": 80}]})).unwrap();
|
||||
let r = s.execute("assets.export", &json!({})).unwrap();
|
||||
assert!(unused(&r).is_empty(), "{r}");
|
||||
let r = s.execute("assets.export", &json!({"formats": [{"format": "svg", "quality": 80}]})).unwrap();
|
||||
assert_eq!(unused(&r), ["`formats[0].quality` was not used: SVG files don't read it"], "{r}");
|
||||
// Settings given are read as the remembered rows write them.
|
||||
s.execute("assets.settings.set", &json!({"formats": [{"format": "ai", "aiVersion": "7"}]})).unwrap();
|
||||
let r = s.execute("assets.export", &json!({"settings": {"ai": {"editingData": false}}})).unwrap();
|
||||
assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
|
||||
s.execute("assets.settings.set", &json!({"formats": [{"format": "png", "quality": 80}]})).unwrap();
|
||||
// So does the export a client prepares.
|
||||
let p = json!({"formats": [{"format": "svg", "quality": 80}]});
|
||||
let r = s.prepare_screen_export("assets.export", &p).unwrap().write(&mut |_: &str, _: &[u8]| Ok(())).unwrap();
|
||||
assert_eq!(unused(&r), ["`formats[0].quality` was not used: SVG files don't read it"], "{r}");
|
||||
}
|
||||
|
||||
/// A preset's options add no notes: Export for Screens notes the options of its request.
|
||||
#[test]
|
||||
fn an_export_preset_adds_no_notes() {
|
||||
let mut s = session(1);
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Mixed", "settings": {"formats": [{"format": "png", "quality": 80}]}})).unwrap();
|
||||
let r = export(&mut s, json!({"exportPreset": "Mixed"}));
|
||||
assert!(unused(&r).is_empty(), "{r}");
|
||||
}
|
||||
|
||||
@@ -6182,3 +6182,66 @@ fn graph_commands_refuse_params_of_the_wrong_type() {
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, null], [2, 3]], "series": null, "transpose": null})).unwrap();
|
||||
assert_eq!(spec(&s).cells(), vec![vec![Some(1.0), None], vec![Some(2.0), Some(3.0)]]);
|
||||
}
|
||||
|
||||
/// A `.ai` in PostScript form whose graph's records read whole (version 7 specification, appendix A)
|
||||
/// opens as a graph whose data can be edited: Graph Data redraws it on its plot. Saving it as `.ai`
|
||||
/// says its data and settings aren't written, in either form.
|
||||
#[test]
|
||||
fn a_graph_opened_from_ai_keeps_its_data_for_editing() {
|
||||
let graph = "%_Gs\n%_100 200 300 100 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\
|
||||
%_1 () Ga\n%_0 13 0 0 0 0 0 () GA\n%_2 () Ga\n%_0 14 0 0 0 0 0 () GA\n%_4 () Ga\n%_0 14 0 0 0 0 0 () GA\n\
|
||||
%_2 2 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n\
|
||||
u\nu\n0.6 0 1 0 k\n110 100 m\n110 150 L\n130 150 L\n130 100 L\nf\nU\n%_2 1 0 0 Go\n\
|
||||
u\n0 0 0 1 K\n100 200 m\n100 100 L\n300 100 L\nS\nU\nU\n%_GS\n";
|
||||
let text = format!(
|
||||
"%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 400 400\n%AI5_FileFormat 3\n%AI3_Cropmarks: 0 0 400 400\n%%EndComments\n\
|
||||
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Graph) Ln\n0 A\n{graph}LB\n\
|
||||
%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n"
|
||||
);
|
||||
let dir = vectorcraft_testkit::temp_dir("graph-ai-open");
|
||||
let path = dir.join("graph.ai");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
let mut found = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if n.extras().graph.is_some() {
|
||||
found.push(n.id);
|
||||
}
|
||||
});
|
||||
assert_eq!(found.len(), 1);
|
||||
let id = found[0];
|
||||
let before = spec(&s, id);
|
||||
assert_eq!(
|
||||
(before.series.as_slice(), before.categories.as_slice(), before.rows.as_slice()),
|
||||
(&["East".to_string()][..], &["Q1".to_string()][..], &[vec![5.0]][..])
|
||||
);
|
||||
// The fill of the series' first mark.
|
||||
let series_fill = |s: &Session| {
|
||||
let mut fill = None;
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if n.extras().series_index == Some(0) && fill.is_none() {
|
||||
let first = n.children().and_then(|c| c.first().cloned());
|
||||
fill = first.and_then(|m| m.appearance.items.iter().find(|i| i.is_fill()).map(|i| i.paint().clone()));
|
||||
}
|
||||
});
|
||||
fill
|
||||
};
|
||||
let painted = series_fill(&s);
|
||||
assert!(painted.is_some());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": ",East\nQ1,8\nQ2,2"})).unwrap();
|
||||
let after = spec(&s, id);
|
||||
assert_eq!(after.rows, [[8.0], [2.0]]);
|
||||
assert_eq!(after.rect, before.rect, "the graph is redrawn on its plot");
|
||||
assert_eq!(series_fill(&s), painted, "the series keeps the file's paint");
|
||||
for (name, version) in [("v7.ai", Some("7")), ("pdf.ai", None)] {
|
||||
let mut p = json!({"path": dir.join(name), "format": "ai"});
|
||||
if let Some(v) = version {
|
||||
p["aiVersion"] = json!(v);
|
||||
}
|
||||
let r = s.execute("file.saveAs", &p).unwrap();
|
||||
let notes = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) → {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 (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)
|
||||
),
|
||||
@@ -105,7 +105,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Complement",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default → {changed}",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default; no undo step when no color changes → {changed}",
|
||||
has_doc,
|
||||
|s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model())
|
||||
),
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! save or Export for Screens: those are checked against the options of the formats the command
|
||||
//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]); and in the
|
||||
//! objects of an array whose keys `MEMBER_KEYS` lists, such as the `items` of
|
||||
//! `object.setLiveShape`.
|
||||
//! `object.setLiveShape` and `addon.pathtools.cornerSet`.
|
||||
//!
|
||||
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
|
||||
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
|
||||
@@ -256,6 +256,22 @@ pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters a command takes instead of one another: (command, key, other key). Giving both,
|
||||
/// neither null, is a parameter error ([`check_exclusive_keys`]).
|
||||
const EXCLUSIVE_KEYS: &[(&str, &str, &str)] = &[("addon.pathtools.cornerSet", "items", "ids"), ("addon.pathtools.cornerRemove", "items", "ids")];
|
||||
|
||||
/// `params` of command `id` give at most one of each pair in [`EXCLUSIVE_KEYS`], or the parameter
|
||||
/// error naming the pair.
|
||||
fn check_exclusive_keys(id: &str, params: &Value) -> Result<()> {
|
||||
let given = |key: &str| params.get(key).filter(|v| !v.is_null());
|
||||
for (_, key, other) in EXCLUSIVE_KEYS.iter().filter(|(c, ..)| *c == id) {
|
||||
if given(key).is_some() && given(other).is_some() {
|
||||
return Err(bad(id, format!("give `{key}` or `{other}`, not both")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of
|
||||
/// them, or all of them.
|
||||
pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
|
||||
@@ -273,7 +289,11 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError
|
||||
|
||||
/// Commands whose params hold an array of objects with keys of their own: the command, the key of
|
||||
/// the array and the keys each of its objects takes.
|
||||
const MEMBER_KEYS: &[(&str, &str, &[&str])] = &[("object.setLiveShape", "items", &["id", "corners"])];
|
||||
const MEMBER_KEYS: &[(&str, &str, &[&str])] = &[
|
||||
("object.setLiveShape", "items", &["id", "corners"]),
|
||||
("addon.pathtools.cornerSet", "items", &["id", "anchors"]),
|
||||
("addon.pathtools.cornerRemove", "items", &["id", "anchors"]),
|
||||
];
|
||||
|
||||
/// Each object in the arrays [`MEMBER_KEYS`] names for command `id` has only the keys it lists, or
|
||||
/// the parameter error naming the first other one with its place (`items[0].corner`), and the
|
||||
@@ -848,9 +868,9 @@ fn check_export_options(id: &str, params: &Value) -> Result<()> {
|
||||
check_option_keys(id, "", given, &keys, why_not, &trees)
|
||||
}
|
||||
|
||||
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included,
|
||||
/// [`check_effect_params`] for the effect `effect.apply` adds and [`check_export_options`] for an
|
||||
/// export. A command the engine doesn't have passes: running it reports that.
|
||||
/// [`check_keys`], [`check_exclusive_keys`] and [`check_member_keys`] for an engine command, the steps of a `command.batch` or an
|
||||
/// `action.batch` included, [`check_effect_params`] for the effect `effect.apply` adds and
|
||||
/// [`check_export_options`] for an export. A command the engine doesn't have passes: running it reports that.
|
||||
pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
check_params_in(id, params, false)
|
||||
}
|
||||
@@ -869,6 +889,7 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
check_keys(id, spec.params, params)?;
|
||||
check_exclusive_keys(id, params)?;
|
||||
check_member_keys(id, params)?;
|
||||
if in_batch {
|
||||
crate::schemas::check(spec, params, false)?;
|
||||
|
||||
@@ -962,7 +962,7 @@ pub(crate) fn ordered_map<T: Sync, R: Send>(
|
||||
// ---------- Outline Stroke ----------
|
||||
|
||||
/// Can Outline Stroke turn `st` into filled art?
|
||||
fn outlinable(st: &StrokeLayer) -> bool {
|
||||
pub(crate) fn outlinable(st: &StrokeLayer) -> bool {
|
||||
st.visible && !st.paint.is_none() && st.width > 0.0
|
||||
}
|
||||
|
||||
|
||||
@@ -793,7 +793,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Preferences",
|
||||
[],
|
||||
None,
|
||||
"{path?} write the preferences as a JSON file {format: \"vectorcraft-preferences\", version: 1, prefs: {key: value, …}}: every preference prefs.list lists and the eyedropper, perspectiveWidget, pathfinder, glyphSnap and pixelSnap groups, without the folder preferences fontsFolder, pluginsFolder, recoveryFolder and templatesFolder and without saved presets; a file over 1 MB, which prefs.import would refuse, is an error and is not written → {path, count}; without path → {data: the file's text, count}",
|
||||
"{path?} write the preferences as a JSON file {format: \"vectorcraft-preferences\", version: 1, prefs: {key: value, …}}: every preference prefs.list lists and the eyedropper, perspectiveWidget, pathfinder, glyphSnap and pixelSnap groups, without the folder preferences fontsFolder, pluginsFolder, scratchPrimary, scratchSecondary, recoveryFolder and templatesFolder and without saved presets; a file over 1 MB, which prefs.import would refuse, is an error and is not written → {path, count}; without path → {data: the file's text, count}",
|
||||
always,
|
||||
export
|
||||
),
|
||||
@@ -802,7 +802,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Preferences",
|
||||
[],
|
||||
None,
|
||||
"{path? | data?: the file's text} apply the preferences of a file prefs.export wrote (at most 1 MB). Every value is checked first, as prefs.set checks it: another format, a newer version, an unknown preference, a value of the wrong type or out of range, or a folder preference is an error, and nothing changes. Preferences the file leaves out keep their values; the open document's units stay as they are; no undo step; not a command.batch step → {imported: count}",
|
||||
"{path? | data?: the file's text} apply the preferences of a file prefs.export wrote (at most 1 MB). Every value is checked first, as prefs.set checks it: another format, a newer version, an unknown preference, a value of the wrong type or out of range, or a folder preference (for scratchPrimary and scratchSecondary, a folder; the choices Startup and None are read) is an error, and nothing changes. Preferences the file leaves out keep their values; the open document's units stay as they are; no undo step; not a command.batch step → {imported: count}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
@@ -818,8 +818,20 @@ pub const PREFS_VERSION: u64 = 1;
|
||||
/// The largest preferences file `prefs.import` reads.
|
||||
const MAX_PREFS_FILE_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Folder preferences, which name folders on one computer: a preferences file leaves them out.
|
||||
const FOLDER_PREFS: &[&str] = &["fontsFolder", "pluginsFolder", "recoveryFolder", "templatesFolder"];
|
||||
/// Folder preferences, which name folders on one computer: a preferences file leaves them out, and
|
||||
/// under automation read roots one outside them is not shown.
|
||||
pub const FOLDER_PREFS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
|
||||
|
||||
/// The scratch disk choices that are not folders.
|
||||
pub const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"];
|
||||
|
||||
/// Does `value`, set as preference `key`, name a folder: is `key` a folder preference
|
||||
/// ([`FOLDER_PREFS`]) and `value`, trimmed as the app uses it, neither empty (the default folder)
|
||||
/// nor a scratch disk choice ([`SCRATCH_CHOICES`])?
|
||||
pub fn names_a_folder(key: &str, value: &str) -> bool {
|
||||
let path = value.trim();
|
||||
FOLDER_PREFS.contains(&key) && !path.is_empty() && !(key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path))
|
||||
}
|
||||
|
||||
/// The preferences `prefs.export` writes: every [`PREF_SPECS`] key but the [`FOLDER_PREFS`], and
|
||||
/// the [`PREF_GROUPS`].
|
||||
@@ -868,7 +880,9 @@ fn read_prefs_file(bytes: &[u8]) -> std::result::Result<Map<String, Value>, Stri
|
||||
if prefs.is_empty() {
|
||||
return Err("the file holds no preferences".into());
|
||||
}
|
||||
if let Some(key) = prefs.keys().find(|k| FOLDER_PREFS.contains(&k.as_str())) {
|
||||
// A scratch disk choice or an empty scratch disk names no folder, and is read.
|
||||
let folder = |k: &str, v: &Value| FOLDER_PREFS.contains(&k) && (!k.starts_with("scratch") || v.as_str().is_some_and(|v| names_a_folder(k, v)));
|
||||
if let Some(key) = prefs.iter().find(|(k, v)| folder(k, v)).map(|(k, _)| k) {
|
||||
return Err(format!("`{key}` names a folder, which preferences files leave out"));
|
||||
}
|
||||
Ok(prefs.clone())
|
||||
@@ -893,14 +907,8 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "imported": values.len() }))
|
||||
}
|
||||
|
||||
/// The preferences that name a folder: the [`FOLDER_PREFS`] and the scratch disks.
|
||||
const PATH_PREFS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
|
||||
|
||||
/// The scratch disk choices that are not folders.
|
||||
const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"];
|
||||
|
||||
/// The preferences as `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show them to the
|
||||
/// request being served. Under automation read roots, a folder preference ([`PATH_PREFS`]) outside
|
||||
/// request being served. Under automation read roots, a folder preference ([`FOLDER_PREFS`]) outside
|
||||
/// them is `null`, as `ui.inspect` leaves it out, and an export preset's `folder` outside them is
|
||||
/// left out. An empty value (the default folder) and a scratch
|
||||
/// disk choice ([`SCRATCH_CHOICES`]) are shown. Without roots the preferences are shown as they are.
|
||||
@@ -911,7 +919,7 @@ fn shown_prefs(prefs: &Prefs) -> Value {
|
||||
}
|
||||
// The app trims a folder before it uses it.
|
||||
let hidden = |path: &str| !path.trim().is_empty() && crate::file_access::check_read(path.trim()).is_err();
|
||||
for key in PATH_PREFS {
|
||||
for key in FOLDER_PREFS {
|
||||
let Some(path) = all[key].as_str() else { continue };
|
||||
if hidden(path) && !(key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path.trim())) {
|
||||
all[key] = Value::Null;
|
||||
|
||||
@@ -15,7 +15,7 @@ use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use super::colorcmds::{NotChanged, Recolor, Scope, keeping_series_paint, left_out};
|
||||
use super::colorcmds::{NotChanged, Recolor, Scope, edit_if_changed, keeping_series_paint, left_out};
|
||||
use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -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. 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 (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
|
||||
),
|
||||
@@ -1661,7 +1661,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let rename = text_param(p, "rename", C)?;
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let (changed, edit, added, converted, not_changed) = s.edit(label, |d, _| {
|
||||
let (changed, edit, added, converted, not_changed) = edit_if_changed(s, label, |now, d| {
|
||||
// The rows' library swatches join the document (one it has with that name and colour is
|
||||
// used as it is).
|
||||
let mut added = vec![];
|
||||
@@ -1716,12 +1716,15 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let not_changed = NotChanged::of(
|
||||
before.iter().filter(|(id, colors, _)| d.node(*id).is_some_and(|now| shown_colors(d, now) == *colors)).map(|(_, _, hidden)| *hidden),
|
||||
);
|
||||
Ok((changed, edit, added, map.converted.get(), not_changed))
|
||||
// Rewriting the group's swatches and relinking its art are not counted in `changed`, so a
|
||||
// pass that counts nothing compares the documents.
|
||||
let any = changed > 0 || !added.is_empty() || *d != *now;
|
||||
Ok(((changed, edit, added, map.converted.get(), not_changed), any))
|
||||
})?;
|
||||
let mut out = json!({ "changed": changed, "swatches": added });
|
||||
not_changed.report(&mut out);
|
||||
if let Some(e) = edit {
|
||||
e.defaults(s);
|
||||
e.defaults(s)?;
|
||||
out["relinked"] = json!(e.relinked());
|
||||
}
|
||||
if converted {
|
||||
|
||||
@@ -1176,6 +1176,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
|
||||
modified: None,
|
||||
notes: Vec::new(),
|
||||
lossy_source: None,
|
||||
kept: serde_json::Map::new(),
|
||||
};
|
||||
let save = fileio::job_for(s, st, plan)?;
|
||||
let meta = Meta {
|
||||
|
||||
@@ -322,7 +322,7 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
|
||||
Ok(unlinked)
|
||||
})?;
|
||||
map_default_paints(s, &mut unlink);
|
||||
map_default_paints(s, &mut unlink)?;
|
||||
Ok(json!({"deleted": names, "unlinked": unlinked}))
|
||||
}
|
||||
|
||||
@@ -660,20 +660,41 @@ impl Relink {
|
||||
renamed || recolored
|
||||
}
|
||||
|
||||
fn defaults(&self, s: &mut Session) {
|
||||
map_default_paints(s, &mut |c, l, t| self.apply(c, l, t));
|
||||
fn defaults(&self, s: &mut Session) -> Result<()> {
|
||||
map_default_paints(s, &mut |c, l, t| self.apply(c, l, t))
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a swatch-link rewrite to the default fill and stroke for new art, except during a live
|
||||
/// preview (Cancel rolls the document back, not the session).
|
||||
pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) {
|
||||
if s.in_interaction() {
|
||||
return;
|
||||
/// Apply a swatch-link rewrite to the fill and stroke new art takes and to the paints of its
|
||||
/// template appearance, except during a live preview (Cancel rolls the document back, not the
|
||||
/// session). A `command.batch` step rewrites them as the command run on its own does: a failed
|
||||
/// batch, and a canceled batch preview, put back the paints the batch started with. A change is
|
||||
/// recorded with the step just made, so Undo and Redo put these paints back with the document.
|
||||
pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) -> Result<()> {
|
||||
if s.in_interaction() && s.batch_stash.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
for p in [&mut s.paint.fill, &mut s.paint.stroke] {
|
||||
p.map_links(f);
|
||||
let start = super::colormgmt::new_art_colors_before(s)?;
|
||||
let pd = &mut s.paint;
|
||||
let template = pd.appearance.iter_mut().flat_map(|a| a.items.iter_mut()).map(|it| match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint,
|
||||
vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
});
|
||||
let mut changed = false;
|
||||
for p in [&mut pd.fill, &mut pd.stroke].into_iter().chain(template) {
|
||||
changed |= p.map_links(f);
|
||||
}
|
||||
if changed {
|
||||
super::colormgmt::record_new_art_colors(s, start)?;
|
||||
// The step takes the paints the command finally leaves, after the selection's appearance
|
||||
// is inherited.
|
||||
let st = s.doc()?;
|
||||
if st.interaction.is_none() {
|
||||
let uid = st.uid;
|
||||
s.batch_paint_docs.push(uid);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a swatch-link rewrite to the document ([`Document::map_solid_paints`]) and to the art of
|
||||
@@ -888,7 +909,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
|
||||
Ok(relinked)
|
||||
})?;
|
||||
relink.defaults(s);
|
||||
relink.defaults(s)?;
|
||||
Ok(json!({"name": to, "relinked": relinked}))
|
||||
}
|
||||
|
||||
@@ -1084,7 +1105,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
|
||||
Ok(relinked)
|
||||
})?;
|
||||
map_default_paints(s, &mut relink);
|
||||
map_default_paints(s, &mut relink)?;
|
||||
Ok(json!({"name": keep, "merged": merged, "relinked": relinked}))
|
||||
}
|
||||
|
||||
@@ -1141,7 +1162,7 @@ fn swatch_spot_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let relinked = map_swatch_links(d, &mut follow_bases(&bases));
|
||||
Ok((relinked, bases))
|
||||
})?;
|
||||
map_default_paints(s, &mut follow_bases(&bases));
|
||||
map_default_paints(s, &mut follow_bases(&bases))?;
|
||||
Ok(json!({"useLab": use_lab, "relinked": relinked}))
|
||||
}
|
||||
|
||||
@@ -1165,8 +1186,11 @@ pub(super) struct GroupEdit {
|
||||
|
||||
impl GroupEdit {
|
||||
/// Apply the link changes to the default fill and stroke for new art.
|
||||
pub(super) fn defaults(&self, s: &mut Session) {
|
||||
self.relinks.iter().for_each(|r| r.defaults(s));
|
||||
pub(super) fn defaults(&self, s: &mut Session) -> Result<()> {
|
||||
if self.relinks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
map_default_paints(s, &mut |c, l, t| self.relinks.iter().any(|r| r.apply(c, l, t)))
|
||||
}
|
||||
|
||||
/// How many paints linked to the group's swatches the rewrite changed.
|
||||
@@ -1242,6 +1266,6 @@ fn swatch_edit_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
colors.into_iter().enumerate().map(|(i, c)| if old.get(i) == Some(&Some(c)) { c } else { super::paint::new_color(s, p, c) }).collect();
|
||||
let rename = str_param(p, "rename");
|
||||
let e = s.edit("Edit Color Group", |d, _| rewrite_group(d, group, &colors, rename, C))?;
|
||||
e.defaults(s);
|
||||
e.defaults(s)?;
|
||||
Ok(json!({"name": e.name, "swatches": e.swatches, "relinked": e.relinked}))
|
||||
}
|
||||
|
||||
@@ -980,7 +980,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(added)
|
||||
})?;
|
||||
if replace {
|
||||
map_default_paints(s, &mut |_, l, _| unlink_dead(&live, l));
|
||||
map_default_paints(s, &mut |_, l, _| unlink_dead(&live, l))?;
|
||||
}
|
||||
Ok(json!({ "added": added }))
|
||||
}
|
||||
|
||||
@@ -34,8 +34,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The Offset a wrap object takes, in points.
|
||||
pub const TEXT_WRAP_OFFSET_RANGE: std::ops::RangeInclusive<f64> = -1000.0..=1000.0;
|
||||
|
||||
fn wrap_param(p: &Value, base: TextWrap) -> TextWrap {
|
||||
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) }
|
||||
let (lo, hi) = (*TEXT_WRAP_OFFSET_RANGE.start(), *TEXT_WRAP_OFFSET_RANGE.end());
|
||||
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(lo, hi), invert: bool_or(p, "invert", base.invert) }
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
@@ -39,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Outlines",
|
||||
["Type"],
|
||||
Some("Cmd+Shift+O"),
|
||||
"{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}",
|
||||
"{} convert the selected text to groups of compound paths (one per glyph); the type object's own fills and strokes (not its characters') go on paths of its whole outline, below or above the glyphs as its Characters row places them; a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}",
|
||||
has_selection,
|
||||
create_outlines
|
||||
),
|
||||
@@ -672,13 +674,39 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
placed.extend(ruby.map(|(_, r)| r));
|
||||
let mut children = placed;
|
||||
// The whole outline the object's own fills and strokes paint: every glyph, ruby glyph
|
||||
// and bar, as the canvas paints them.
|
||||
let mut all = kurbo::BezPath::new();
|
||||
for g in lay.glyphs.iter().chain(&lay.ruby) {
|
||||
all.extend(g.outline.iter());
|
||||
}
|
||||
// Underline and strikethrough bars become paths of their run's paint too (#847).
|
||||
for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t) {
|
||||
all.extend(bar.iter());
|
||||
let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
|
||||
let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None);
|
||||
node.appearance = st.appearance().into();
|
||||
children.push(Arc::new(node));
|
||||
}
|
||||
// The object's own fills and strokes (not its characters') paint the whole outline:
|
||||
// those below the Characters row under the characters, the others over them, with
|
||||
// their unplaced gradients fixed to the layout's bounds in the document.
|
||||
let all = PathData::from_bezpath(&all).transformed(t.xf);
|
||||
if !n.appearance.items.is_empty() && !all.is_empty() {
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
let tb = t.xf.transform_rect_bbox(lay.bounds);
|
||||
let mut whole = |items: &[AppearanceItem]| {
|
||||
let mut ap = Appearance { items: items.to_vec(), ..Default::default() };
|
||||
ap.pin_gradients(tb);
|
||||
Arc::new(Node::path(d.alloc_id(), all.clone(), ap))
|
||||
};
|
||||
if !below.is_empty() {
|
||||
children.insert(0, whole(below));
|
||||
}
|
||||
if !above.is_empty() {
|
||||
children.push(whole(above));
|
||||
}
|
||||
}
|
||||
let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?;
|
||||
d.remove(tid)?;
|
||||
if children.is_empty() {
|
||||
|
||||
@@ -1688,10 +1688,9 @@ fn warichu_is_a_character_attribute_set_saved_and_exported() {
|
||||
assert!(["warichu", "warichuLines", "warichuGap", "warichuScale", "warichuAlign"].iter().all(|k| saved.get(*k).is_none()), "{saved}");
|
||||
let plain_svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||||
let plain_pdf = vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
|
||||
let text_ops = |pdf: &[u8]| {
|
||||
let s = String::from_utf8_lossy(pdf);
|
||||
s.contains("Tj") || s.contains("TJ")
|
||||
};
|
||||
// Only text objects count: the file ID in the trailer and the XMP is base64 that changes each
|
||||
// second and now and then spells `Tj` or `TJ`.
|
||||
let text_ops = |pdf: &[u8]| String::from_utf8_lossy(pdf).lines().any(|l| l.starts_with("BT ") && (l.contains("Tj") || l.contains("TJ")));
|
||||
assert!(plain_svg.contains("<text") && text_ops(&plain_pdf));
|
||||
|
||||
// text.setFormat with the values the characters already have changes nothing and records no
|
||||
|
||||
@@ -867,7 +867,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Data Sets…",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{path? | data? | dataBase64? | csv?} replace every dataset with the rows of a CSV, comma- or tab-separated, in UTF-16 with a byte-order mark, UTF-8 or else Windows-1252 (header: one variable name per column, whatever its case; one row per dataset, named Data Set 1, 2… in file order). The document's variables and their bindings stay. A header that names no variable defines one, unbound: `#Name` defines a visibility variable, `@Name` defines an image variable, and a header without a prefix defines a text variable. A prefixed header whose name is a variable of another kind, and a `%` header that isn't a variable's exact name (graph data files), are refused, and so are two headers that name one variable and a header that would define a variable while the variables are locked. An import that leaves the variables and data sets as they were adds no undo step → {imported, names, defined, deletedDataSets}",
|
||||
"{path? | data? | dataBase64? | csv?} replace every dataset with the rows of a CSV, comma- or tab-separated, in UTF-16 (with a byte-order mark, or without one when at least half of its two-byte units hold a NUL byte and only one byte order reads a line break; any other file with NUL bytes is refused), UTF-8 or else Windows-1252 (header: one variable name per column, whatever its case; one row per dataset, named Data Set 1, 2… in file order). The document's variables and their bindings stay. A header that names no variable defines one, unbound: `#Name` defines a visibility variable, `@Name` defines an image variable, and a header without a prefix defines a text variable. A prefixed header whose name is a variable of another kind, and a `%` header that isn't a variable's exact name (graph data files), are refused, and so are two headers that name one variable and a header that would define a variable while the variables are locked. An import that leaves the variables and data sets as they were adds no undo step → {imported, names, defined, deletedDataSets}",
|
||||
has_doc,
|
||||
dataset_import
|
||||
),
|
||||
|
||||
@@ -32,20 +32,17 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result<(Vec<u8>, bool)> {
|
||||
}
|
||||
|
||||
/// The text of a datasets CSV given as a file or as `dataBase64`: UTF-16 after a UTF-16
|
||||
/// byte-order mark, else UTF-8, with or without a byte-order mark, else Windows-1252. A file
|
||||
/// that starts with a byte-order mark must be valid in its encoding, and a file without one must
|
||||
/// not hold NUL bytes (UTF-16 without a byte-order mark).
|
||||
/// byte-order mark, else UTF-16 without one when the file holds NUL bytes (see
|
||||
/// [`utf16_without_mark`]), else UTF-8, with or without a byte-order mark, else Windows-1252. A
|
||||
/// file that starts with a byte-order mark must be valid in its encoding, and a file with NUL
|
||||
/// bytes and no byte-order mark must read as UTF-16 by [`utf16_without_mark`]'s rule.
|
||||
fn datasets_csv_text(bytes: &[u8], cmd: &str) -> Result<String> {
|
||||
let utf16 = |rest: &[u8], le: bool| {
|
||||
let (units, odd) = rest.as_chunks::<2>();
|
||||
let units = units.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
|
||||
let text = char::decode_utf16(units).collect::<std::result::Result<String, _>>();
|
||||
text.ok().filter(|_| odd.is_empty()).ok_or_else(|| bad(cmd, "CSV is not UTF-16"))
|
||||
};
|
||||
match bytes {
|
||||
[0xFF, 0xFE, rest @ ..] => return utf16(rest, true),
|
||||
[0xFE, 0xFF, rest @ ..] => return utf16(rest, false),
|
||||
_ if bytes.contains(&0) => return Err(bad(cmd, "CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark")),
|
||||
[0xFF, 0xFE, rest @ ..] => return utf16(rest, true).ok_or_else(|| bad(cmd, "CSV is not UTF-16")),
|
||||
[0xFE, 0xFF, rest @ ..] => return utf16(rest, false).ok_or_else(|| bad(cmd, "CSV is not UTF-16")),
|
||||
_ if bytes.contains(&0) => {
|
||||
return utf16_without_mark(bytes).ok_or_else(|| bad(cmd, "CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match std::str::from_utf8(bytes) {
|
||||
@@ -55,6 +52,34 @@ fn datasets_csv_text(bytes: &[u8], cmd: &str) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` as UTF-16, little-endian when `le` is set, or `None` when they aren't valid UTF-16.
|
||||
fn utf16(bytes: &[u8], le: bool) -> Option<String> {
|
||||
let (units, odd) = bytes.as_chunks::<2>();
|
||||
let units = units.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
|
||||
char::decode_utf16(units).collect::<std::result::Result<String, _>>().ok().filter(|_| odd.is_empty())
|
||||
}
|
||||
|
||||
/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16, or `None`. At least half of
|
||||
/// the two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with
|
||||
/// fewer, such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is
|
||||
/// the one that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or
|
||||
/// U+0D00, so a file that reads one in both orders is refused. A file that reads none in either
|
||||
/// order has no data rows, and is read little-endian so that the import reports that. The text
|
||||
/// must be valid UTF-16 in that order and hold no NUL character.
|
||||
fn utf16_without_mark(bytes: &[u8]) -> Option<String> {
|
||||
let (units, odd) = bytes.as_chunks::<2>();
|
||||
if !odd.is_empty() || units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() {
|
||||
return None;
|
||||
}
|
||||
let is_line_break = |u: u16| matches!(u, 0x0A | 0x0D);
|
||||
let lines_le = units.iter().any(|&c| is_line_break(u16::from_le_bytes(c)));
|
||||
let lines_be = units.iter().any(|&c| is_line_break(u16::from_be_bytes(c)));
|
||||
if lines_le && lines_be {
|
||||
return None;
|
||||
}
|
||||
utf16(bytes, !lines_be).filter(|text| !text.contains('\0'))
|
||||
}
|
||||
|
||||
/// A row of a datasets CSV: the file line it starts on, its cells, and for each cell whether it was quoted.
|
||||
type CsvRow = (usize, Vec<String>, Vec<bool>);
|
||||
|
||||
|
||||
@@ -369,12 +369,13 @@ impl DocState {
|
||||
/// menu asks on every frame it is open, and measuring builds the solids of 3D effects, so the
|
||||
/// answer is kept for this version of the document and those artboards, and a change of the
|
||||
/// selection or the view keeps it. It is measured again when what the measure reads outside the
|
||||
/// document changes: the installed plug-ins, whose effects draw art, and the installed fonts,
|
||||
/// which set the bounds of all type.
|
||||
/// document changes: the installed plug-ins, whose effects draw art, and the installed fonts and
|
||||
/// the hyphenation exceptions, which set the bounds of all type.
|
||||
pub fn any_art_to_fit(&self, indices: &[usize]) -> bool {
|
||||
self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::FontDb::global().generation()))
|
||||
self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::layout_inputs()))
|
||||
}
|
||||
/// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the fonts.
|
||||
/// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the layout inputs
|
||||
/// ([`vectorcraft_text::layout_inputs`]).
|
||||
pub(crate) fn any_art_to_fit_with(&self, indices: &[usize], outside: (u64, u64)) -> bool {
|
||||
// Measuring runs renderer and plug-in code; should it ever ask again while measuring, it
|
||||
// is measured without being kept rather than borrowed twice.
|
||||
|
||||
@@ -33,6 +33,7 @@ pub use cmd::params::{check_keys, check_params};
|
||||
pub use cmd::pathops::auto_simplify_level;
|
||||
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
|
||||
pub use cmd::select::{path_fingerprint, storable_selection, stored_path_params, stored_path_step};
|
||||
pub use cmd::textwrap::TEXT_WRAP_OFFSET_RANGE;
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use cmd::{EyedropperOptions, PathfinderOptions};
|
||||
pub use journal::Journal;
|
||||
|
||||
@@ -25,9 +25,11 @@ impl Session {
|
||||
// Clicks find an image's own fills and strokes where their effects draw them.
|
||||
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
|
||||
// Clicks find a blend's steps and an envelope's distorted art where the canvas draws them,
|
||||
// evaluated again when the fonts or the plug-ins change.
|
||||
// evaluated again when the fonts, the hyphenation exceptions or the plug-ins change.
|
||||
vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || {
|
||||
vectorcraft_text::FontDb::global().generation().rotate_left(32) ^ vectorcraft_render::effects::plugin_revision()
|
||||
let mut h = std::hash::DefaultHasher::new();
|
||||
std::hash::Hash::hash(&(vectorcraft_text::layout_inputs(), vectorcraft_render::effects::plugin_revision()), &mut h);
|
||||
std::hash::Hasher::finish(&h)
|
||||
});
|
||||
// Envelopes map type from the bounds of its glyph outlines, underlines and strikethroughs.
|
||||
vectorcraft_text::register_envelope_ink();
|
||||
|
||||
@@ -560,6 +560,125 @@ fn reset_bounding_box_then_release_intertwine() {
|
||||
reset_then_release("object.intertwine.make", "object.intertwine.release");
|
||||
}
|
||||
|
||||
/// A symbol of two rectangles, the first turned `inner` degrees before the symbol is made, placed
|
||||
/// as an instance that is then turned 20°.
|
||||
fn turned_instance(inner: f64) -> (Session, NodeId) {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
|
||||
let b = rect(&mut s, 150.0, 220.0, 100.0, 50.0);
|
||||
if inner != 0.0 {
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.rotate", &json!({"angle": inner})).unwrap();
|
||||
}
|
||||
sel(&mut s, &[a, b]);
|
||||
let inst = id_of(&s.execute("symbol.new", &json!({"name": "Pair"})).unwrap());
|
||||
sel(&mut s, &[inst]);
|
||||
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
|
||||
assert!(near(angle(&s, inst), 20.0));
|
||||
(s, inst)
|
||||
}
|
||||
|
||||
/// Break Link and Expand with the parameters that turn instance `inst` into its art.
|
||||
fn expand_instance(s: &mut Session, command: &str, inst: NodeId) {
|
||||
sel(s, &[inst]);
|
||||
let params = if command == "symbol.breakLink" { json!({"ids": [inst.0]}) } else { json!({"object": true, "fill": false, "stroke": false}) };
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(inst).is_none_or(|n| !matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. })), "{command}");
|
||||
}
|
||||
|
||||
/// The angles of the objects inside the document's only top-level object, that object included.
|
||||
fn art_angles(s: &Session) -> Vec<f64> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let layer = d.layers.first().unwrap();
|
||||
let top = layer.children().unwrap();
|
||||
assert_eq!(top.len(), 1);
|
||||
let mut out = vec![];
|
||||
subtree_angles(&top[0], &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_bounding_box_then_expand_symbol_instance() {
|
||||
for command in ["symbol.breakLink", "object.expand"] {
|
||||
let (mut s, inst) = turned_instance(0.0);
|
||||
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
|
||||
assert_eq!(angle(&s, inst), 0.0);
|
||||
expand_instance(&mut s, command, inst);
|
||||
let angles = art_angles(&s);
|
||||
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, 0.0)), "{command}: {angles:?}");
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
assert_eq!(selection_box(&s).angle, 0.0, "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_a_turned_symbol_instance_keeps_its_angle() {
|
||||
for command in ["symbol.breakLink", "object.expand"] {
|
||||
let (mut s, inst) = turned_instance(0.0);
|
||||
expand_instance(&mut s, command, inst);
|
||||
let angles = art_angles(&s);
|
||||
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, 20.0)), "{command}: {angles:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_bounding_box_then_expand_keeps_the_angles_inside_the_symbol() {
|
||||
for command in ["symbol.breakLink", "object.expand"] {
|
||||
let (mut s, inst) = turned_instance(30.0);
|
||||
s.execute("object.resetBoundingBox", &json!({})).unwrap();
|
||||
expand_instance(&mut s, command, inst);
|
||||
// The turned rectangle keeps the 30° it had in the symbol; the rest stand square.
|
||||
let mut angles = art_angles(&s);
|
||||
angles.sort_by(f64::total_cmp);
|
||||
assert!(near(*angles.last().unwrap(), 30.0) && angles[..angles.len() - 1].iter().all(|a| near(*a, 0.0)), "{command}: {angles:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The group that an instance's opacity adds around the art of a one-object symbol stands as the
|
||||
/// instance's box does, turned or reset.
|
||||
#[test]
|
||||
fn expanding_a_faded_symbol_instance_gives_its_group_the_instance_box() {
|
||||
for command in ["symbol.breakLink", "object.expand"] {
|
||||
for reset in [false, true] {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
|
||||
sel(&mut s, &[a]);
|
||||
let inst = id_of(&s.execute("symbol.new", &json!({"name": "One"})).unwrap());
|
||||
sel(&mut s, &[inst]);
|
||||
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
|
||||
s.execute("object.setProps", &json!({"ids": [inst.0], "opacity": 50})).unwrap();
|
||||
if reset {
|
||||
s.execute("object.resetBoundingBox", &json!({})).unwrap();
|
||||
}
|
||||
expand_instance(&mut s, command, inst);
|
||||
let want = if reset { 0.0 } else { 20.0 };
|
||||
let angles = art_angles(&s);
|
||||
assert!(angles.len() == 2 && angles.iter().all(|a| near(*a, want)), "{command}, reset {reset}: {angles:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mirrored instance: turned 170° and then reflected, its box stands 180° from the box its
|
||||
/// transform gives in one step. The art takes the instance's box, and stands square after a reset.
|
||||
#[test]
|
||||
fn expanding_a_reflected_symbol_instance_keeps_its_box() {
|
||||
for command in ["symbol.breakLink", "object.expand"] {
|
||||
for reset in [false, true] {
|
||||
let (mut s, inst) = turned_instance(0.0);
|
||||
s.execute("object.rotate", &json!({"angle": 150})).unwrap();
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert!(near(angle(&s, inst), -170.0), "{}", angle(&s, inst));
|
||||
if reset {
|
||||
s.execute("object.resetBoundingBox", &json!({})).unwrap();
|
||||
}
|
||||
let want = angle(&s, inst);
|
||||
expand_instance(&mut s, command, inst);
|
||||
let angles = art_angles(&s);
|
||||
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, want)), "{command}, reset {reset}: {angles:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_bounding_box_then_release_linked_opacity_mask() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1748,6 +1748,203 @@ fn shape_builder_arrow_keys_pass_swatches_of_the_same_color() {
|
||||
assert_eq!(s.paint.fill, after, "the swatch after Twin B");
|
||||
}
|
||||
|
||||
/// The colors of the cursor swatch preview's chips, left to right, and which one is ringed.
|
||||
fn swatch_chips(s: &mut Session, v: ViewInfo) -> Vec<([u8; 4], bool)> {
|
||||
let mut chips: Vec<(f64, [u8; 4], bool)> = s
|
||||
.overlays(v)
|
||||
.into_iter()
|
||||
.filter_map(|o| match o {
|
||||
vectorcraft_tools::Overlay::Swatch { p, color, selected } => Some((p.x, color, selected)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
chips.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
chips.into_iter().map(|(_, c, sel)| (c, sel)).collect()
|
||||
}
|
||||
|
||||
/// A solid paint's color as the chips draw it.
|
||||
fn chip_color(p: &Paint) -> [u8; 4] {
|
||||
match p {
|
||||
Paint::Solid { color, .. } => color.to_rgba8(1.0),
|
||||
_ => panic!("not a solid color: {p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Live Paint Bucket: Left and Right step the active paint through the document's color swatches
|
||||
/// (the selected art keeps its paint), the Cursor Swatch Preview (on at first) shows the active
|
||||
/// paint ringed between the swatches before and after it, and with it off the arrows still step.
|
||||
#[test]
|
||||
fn live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches() {
|
||||
let mut s = session();
|
||||
let (a, _) = two(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
assert_eq!(s.tool_options_of("livePaintBucket")["cursorSwatchPreview"], json!(true));
|
||||
assert!(s.tool_claims_key(ToolKey::Right, v) && s.tool_claims_key(ToolKey::Left, v));
|
||||
assert!(!s.tool_claims_key(ToolKey::Up, v), "Up and Down keep their own use");
|
||||
let list: Vec<(String, Paint)> =
|
||||
vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| (sw.name.clone(), sw.paint.clone())).collect();
|
||||
assert!(list.len() >= 4);
|
||||
let n = list.len();
|
||||
let stroke = s.paint.stroke.clone();
|
||||
s.execute("paint.setFill", &json!({"swatch": list[0].0, "ids": []})).unwrap();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[2].1);
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[1].1);
|
||||
assert_eq!(s.paint.stroke, stroke, "the stroke stays");
|
||||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "A keeps its fill");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0), v).unwrap();
|
||||
assert_eq!(swatch_chips(&mut s, v), vec![(chip_color(&list[0].1), false), (chip_color(&list[1].1), true), (chip_color(&list[2].1), false)]);
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0).with_mods(alt), v).unwrap();
|
||||
assert!(swatch_chips(&mut s, v).is_empty(), "Alt samples with the eyedropper and shows no chips");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0), v).unwrap();
|
||||
// Past the first swatch, Left wraps around to the last.
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[n - 1].1);
|
||||
assert_eq!(swatch_chips(&mut s, v).iter().map(|c| c.0).collect::<Vec<_>>(), [&list[n - 2].1, &list[n - 1].1, &list[0].1].map(chip_color));
|
||||
// With the Stroke proxy in front, the arrows step the stroke and the chips show it.
|
||||
s.execute("paint.toggleActive", &json!({"fill": false})).unwrap();
|
||||
let fill = s.paint.fill.clone();
|
||||
s.execute("paint.setStroke", &json!({"swatch": list[1].0, "ids": []})).unwrap();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &list[2].1));
|
||||
assert!(!s.fill_active, "the Stroke proxy stays in front");
|
||||
assert_eq!(swatch_chips(&mut s, v)[1], (chip_color(&list[2].1), true));
|
||||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "A keeps its paint");
|
||||
// Cursor Swatch Preview off hides the chips; the arrows still step.
|
||||
s.set_tool_option_cmd(&json!({"tool": "livePaintBucket", "values": {"cursorSwatchPreview": false}})).unwrap();
|
||||
assert!(swatch_chips(&mut s, v).is_empty());
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.stroke, list[3].1);
|
||||
}
|
||||
|
||||
/// The Live Paint Bucket and the Shape Builder take Left and Right for swatches only without
|
||||
/// modifiers: Shift, Alt and Cmd/Ctrl arrows are left to their shortcuts and change no paint.
|
||||
#[test]
|
||||
fn swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts() {
|
||||
let mut s = session();
|
||||
two(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
let first = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc)[0].name.clone();
|
||||
s.execute("paint.setFill", &json!({"swatch": first, "ids": []})).unwrap();
|
||||
for tool in ["livePaintBucket", "shapeBuilder"] {
|
||||
s.select_tool(tool, v).unwrap();
|
||||
if tool == "shapeBuilder" {
|
||||
s.set_tool_option_cmd(&json!({"tool": tool, "values": {"pickColorFrom": "swatches"}})).unwrap();
|
||||
}
|
||||
let fill = s.paint.fill.clone();
|
||||
assert!(s.tool_claims_key_with(ToolKey::Right, Mods::default(), v), "{tool}");
|
||||
for m in [Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }, Mods { cmd: true, ..Default::default() }] {
|
||||
for key in [ToolKey::Left, ToolKey::Right] {
|
||||
assert!(!s.tool_claims_key_with(key, m, v), "{tool} {key:?} {m:?}");
|
||||
s.tool_key(key, m, v).unwrap();
|
||||
assert_eq!(s.paint.fill, fill, "{tool} {key:?} {m:?}");
|
||||
}
|
||||
}
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_ne!(s.paint.fill, fill, "{tool}: a plain Right steps");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two swatches of the same color: the Bucket's arrows step through both, and the fill and the
|
||||
/// stroke each step on from the swatch picked for them.
|
||||
#[test]
|
||||
fn live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke() {
|
||||
let mut s = session();
|
||||
two(&mut s);
|
||||
for name in ["Twin A", "Twin B"] {
|
||||
s.execute("swatch.new", &json!({"name": name, "color": "#123456"})).unwrap();
|
||||
}
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
let names: Vec<String> = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| sw.name.clone()).collect();
|
||||
let a = names.iter().position(|n| n == "Twin A").unwrap();
|
||||
assert_eq!(names[a + 1], "Twin B");
|
||||
let after = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc)[(a + 2) % names.len()].paint.clone();
|
||||
// The fill goes to Twin B.
|
||||
s.execute("paint.setFill", &json!({"swatch": "Twin A", "ids": []})).unwrap();
|
||||
let twin = s.paint.fill.clone();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, twin, "Twin B");
|
||||
// The stroke starts at Twin A: Right goes to Twin B, then past it.
|
||||
s.execute("paint.setStroke", &json!({"swatch": "Twin A", "ids": []})).unwrap();
|
||||
assert!(!s.fill_active);
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.stroke, twin, "Twin B for the stroke");
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.stroke, after, "the swatch after Twin B");
|
||||
// Back on the fill, which is still Twin B: Right goes past it.
|
||||
s.execute("paint.toggleActive", &json!({"fill": true})).unwrap();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, after, "the fill steps on from Twin B");
|
||||
}
|
||||
|
||||
/// A color that is no swatch shows alone and Right picks the first swatch, Left the last; a paint
|
||||
/// that is not a solid color shows no chip; one swatch steps to itself; with no color swatch the
|
||||
/// tool leaves the arrows alone; during a fill drag the arrows change nothing.
|
||||
#[test]
|
||||
fn live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags() {
|
||||
let mut s = session();
|
||||
live(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0), v).unwrap();
|
||||
let list: Vec<Paint> = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| sw.paint.clone()).collect();
|
||||
let odd = Paint::solid(Color::rgb8(0x12, 0x34, 0x57));
|
||||
assert!(!list.contains(&odd));
|
||||
s.paint.fill = odd.clone();
|
||||
assert_eq!(swatch_chips(&mut s, v), vec![(chip_color(&odd), true)]);
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[0]);
|
||||
s.paint.fill = odd.clone();
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[list.len() - 1]);
|
||||
s.paint.fill = Paint::None;
|
||||
assert!(swatch_chips(&mut s, v).is_empty());
|
||||
|
||||
// During a fill drag the arrows are not taken and change nothing.
|
||||
s.paint.fill = list[0].clone();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap();
|
||||
assert!(s.tool_busy());
|
||||
assert!(!s.tool_claims_key(ToolKey::Right, v));
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[0]);
|
||||
assert!(swatch_chips(&mut s, v).is_empty(), "no chips during a drag");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 25.0, 50.0), v).unwrap();
|
||||
|
||||
// One color swatch: both arrows pick it, and it stands on both sides of itself.
|
||||
let one = list[0].clone();
|
||||
s.edit("t", |d, _| {
|
||||
d.swatch_groups.clear();
|
||||
d.swatches.retain(|sw| sw.paint == one);
|
||||
d.swatches.truncate(1);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).len(), 1);
|
||||
s.paint.fill = odd;
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, one);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 301.0, 300.0), v).unwrap();
|
||||
assert_eq!(swatch_chips(&mut s, v), vec![(chip_color(&one), false), (chip_color(&one), true), (chip_color(&one), false)]);
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, one);
|
||||
|
||||
// No color swatch: the arrows are left to the canvas and the paint stays.
|
||||
s.edit("t", |d, _| {
|
||||
d.swatches.clear();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!s.tool_claims_key(ToolKey::Right, v) && !s.tool_claims_key(ToolKey::Left, v));
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, one);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_tool_alt_deletes_line_pieces() {
|
||||
let mut s = session();
|
||||
@@ -2446,6 +2643,193 @@ fn live_paint_stroke_edge() {
|
||||
assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [300, 300]})).is_err());
|
||||
}
|
||||
|
||||
/// The undo and redo depths, the unsaved state and the revision of the active document.
|
||||
fn history_state(s: &Session) -> (usize, usize, bool, u64) {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty(), st.revision)
|
||||
}
|
||||
|
||||
/// Draw a rectangle and undo it, so a redo step is waiting, then mark the document saved with `g`
|
||||
/// alone selected.
|
||||
fn redo_waiting_and_saved(s: &mut Session, g: NodeId) {
|
||||
rect(s, 300.0, 300.0, 10.0, 10.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
}
|
||||
|
||||
/// The active document and its [`history_state`].
|
||||
type Snapshot = (vectorcraft_doc::Document, (usize, usize, bool, u64));
|
||||
|
||||
fn snapshot(s: &Session) -> Snapshot {
|
||||
((*s.doc().unwrap().doc).clone(), history_state(s))
|
||||
}
|
||||
|
||||
/// After a Live Paint edit that changed nothing: the document, the undo and redo steps, the saved
|
||||
/// state and the revision are as `before`, and Redo still brings back the rectangle
|
||||
/// [`redo_waiting_and_saved`] undid. Undoes the rectangle again, marks the document saved and
|
||||
/// takes a new `before`.
|
||||
fn assert_unchanged(s: &mut Session, before: &mut Snapshot, what: &str) {
|
||||
assert!(*s.doc().unwrap().doc == before.0, "{what}: the document is as it was");
|
||||
assert_eq!(history_state(s), before.1, "{what}: no undo step, the redo step kept, still saved");
|
||||
let children = layer_children(s);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(layer_children(s), children + 1, "{what}: Redo brings back the rectangle");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
*before = snapshot(s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_fill_same_paint_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let drag = json!({"group": g.0, "point": [25, 50], "points": [[25, 50], [125, 50]], "color": "#00ff00"});
|
||||
s.execute("livePaint.fill", &drag).unwrap();
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
// A color used since stays the newest recent color and the last color.
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
s.remember_paint_now(&Paint::solid(blue));
|
||||
let recent = (s.recent_colors.clone(), s.last_solid);
|
||||
let mut before = snapshot(&s);
|
||||
let click = s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "color": "#00ff00"})).unwrap();
|
||||
let face = vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(75.0, 50.0)).unwrap().id;
|
||||
assert_eq!(click, json!({"group": g.0, "face": face.0}), "the usual reply");
|
||||
assert_unchanged(&mut s, &mut before, "a click on a face with the paint");
|
||||
s.execute("livePaint.fill", &drag).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "a drag over faces with the paint");
|
||||
// Without a group, the click finds the Live Paint group among the selected objects.
|
||||
s.execute("livePaint.fill", &json!({"point": [125, 50], "color": "#00ff00"})).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "a click in the selected group");
|
||||
assert_eq!((s.recent_colors.clone(), s.last_solid), recent, "the recent colors are as they were");
|
||||
// A drag that reaches one face without the paint is one undo step and remembers the color.
|
||||
let red = Paint::solid(Color::from_hex("#ff0000").unwrap());
|
||||
for x in [25, 75] {
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [x, 50], "color": "#ff0000"})).unwrap();
|
||||
}
|
||||
s.remember_paint_now(&Paint::solid(blue));
|
||||
let depth = s.doc().unwrap().history.undo.len();
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "points": [[25, 50], [125, 50]], "color": "#ff0000"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1);
|
||||
assert_eq!(Paint::solid(s.last_solid), red);
|
||||
let gn = node(&s, g);
|
||||
for x in [25.0, 75.0, 125.0] {
|
||||
assert_eq!(vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().appearance.fill_paint(), red, "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_bucket_click_with_the_face_paint_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0));
|
||||
let click = |s: &mut Session, x: f64| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, x, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, x, 50.0), v).unwrap();
|
||||
};
|
||||
click(&mut s, 125.0);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
click(&mut s, 125.0);
|
||||
assert_unchanged(&mut s, &mut before, "a Bucket click on a face with the paint");
|
||||
// A drag over faces with the paint changes nothing either.
|
||||
click(&mut s, 25.0);
|
||||
click(&mut s, 75.0);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 125.0, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "a Bucket drag over faces with the paint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_stroke_edge_same_paint_and_width_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let call = json!({"group": g.0, "point": [100, 50], "color": "#ff0000", "width": 3});
|
||||
let first = s.execute("livePaint.strokeEdge", &call).unwrap();
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
s.remember_paint_now(&Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||||
let last = s.last_solid;
|
||||
let mut before = snapshot(&s);
|
||||
assert_eq!(s.execute("livePaint.strokeEdge", &call).unwrap(), first, "the usual reply");
|
||||
assert_unchanged(&mut s, &mut before, "an edge with the paint and the width");
|
||||
assert_eq!(s.last_solid, last, "the last color is as it was");
|
||||
// Another width is an edit.
|
||||
s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "color": "#ff0000", "width": 2})).unwrap();
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_gap_options_same_values_are_no_op() {
|
||||
let mut s = session();
|
||||
let g = live_square_with_a_gap(&mut s);
|
||||
let set = json!({"detect": true, "size": "small", "color": "#123456"});
|
||||
let first = s.execute("livePaint.gapOptions", &set).unwrap();
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
assert_eq!(s.execute("livePaint.gapOptions", &set).unwrap(), first, "the usual reply");
|
||||
assert_unchanged(&mut s, &mut before, "the options the group has");
|
||||
s.execute("livePaint.gapOptions", &json!({})).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "no options given");
|
||||
// Close Gaps With Paths with a gap to close is an edit.
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"closeGaps": true})).unwrap();
|
||||
assert_eq!(r["closed"], 1);
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_gap_options_close_without_gaps_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true})).unwrap();
|
||||
assert_eq!((ids(&r), &r["faces"], &r["closed"], gaps_of(&r).len()), (vec![g], &json!(3), &json!(0), 0));
|
||||
assert_unchanged(&mut s, &mut before, "no gap to close");
|
||||
// With other options, the faces keep their ids when no path is added.
|
||||
let faces = |s: &Session| vectorcraft_tools::builder::faces(&node(s, g)).iter().map(|f| f.id).collect::<Vec<_>>();
|
||||
let ids_before = faces(&s);
|
||||
s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true, "color": "#00ff00"})).unwrap();
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
assert_eq!(faces(&s), ids_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_make_existing_group_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
let r = s.execute("livePaint.make", &json!({})).unwrap();
|
||||
assert_eq!((NodeId(r["id"].as_u64().unwrap()), &r["faces"]), (g, &json!(3)));
|
||||
assert_eq!(r["edges"].as_u64().unwrap(), vectorcraft_tools::builder::edges(&node(&s, g)).len() as u64);
|
||||
assert_unchanged(&mut s, &mut before, "Make with the group alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_merge_single_group_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let other = rect(&mut s, 300.0, 0.0, 50.0, 50.0);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
// Given by id, the group is selected without an undo step.
|
||||
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
|
||||
let before = snapshot(&s);
|
||||
let r = s.execute("livePaint.merge", &json!({"ids": [g.0]})).unwrap();
|
||||
assert_eq!((NodeId(r["id"].as_u64().unwrap()), &r["faces"]), (g, &json!(3)));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
assert!(*s.doc().unwrap().doc == before.0);
|
||||
let (undo, redo, dirty, _) = history_state(&s);
|
||||
assert_eq!((undo, redo, dirty), (before.1.0, before.1.1, before.1.2));
|
||||
// A path to add is an edit.
|
||||
s.execute("livePaint.merge", &json!({"ids": [g.0, other.0]})).unwrap();
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
}
|
||||
|
||||
/// Shape Builder refuses an on/off option that is not true or false and a tolerance or gap that is
|
||||
/// not a number, given as text or as another value of the wrong kind, naming the key; the document
|
||||
/// and the undo history stay as they were. True, false and null still work.
|
||||
@@ -3097,9 +3481,10 @@ fn live_paint_tool_options_refuse_invalid_values_and_change_nothing() {
|
||||
let colors = [json!(3), json!("nope"), json!("#12345"), json!("#00ff0080"), json!("00ff0080"), json!("#0f08"), json!(null)];
|
||||
let widths = [json!("wide"), json!(null), json!(true)];
|
||||
let switches = [json!("true"), json!(null), json!(1)];
|
||||
for (tool, keys) in
|
||||
[("livePaintBucket", &["paintFills", "paintStrokes", "highlight"][..]), ("livePaintSelection", &["selectFills", "selectStrokes"][..])]
|
||||
{
|
||||
for (tool, keys) in [
|
||||
("livePaintBucket", &["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight"][..]),
|
||||
("livePaintSelection", &["selectFills", "selectStrokes"][..]),
|
||||
] {
|
||||
let mut bad: Vec<(&str, Value)> = keys.iter().flat_map(|k| switches.iter().map(move |v| (*k, v.clone()))).collect();
|
||||
bad.extend(colors.iter().map(|v| ("color", v.clone())));
|
||||
bad.extend(widths.iter().map(|v| ("width", v.clone())));
|
||||
|
||||
@@ -184,6 +184,25 @@ fn pencil_alt_closes_path() {
|
||||
assert!(ps[0].1.is_closed());
|
||||
}
|
||||
|
||||
/// A Pencil stroke drawn as a dome and closed with Alt at the release closes with a straight segment
|
||||
/// across the bottom, from where the stroke ends to where it starts: the path stays within where the
|
||||
/// pen went, rather than curving on past both ends and back like a hat's brim.
|
||||
#[test]
|
||||
fn pencil_alt_closes_a_stroke_straight_back_to_its_start() {
|
||||
let mut s = session();
|
||||
let pts: Vec<(f64, f64)> =
|
||||
(0..=60).map(|i| f64::from(i) * std::f64::consts::PI / 60.0).map(|a| (300.0 - 150.0 * a.cos(), 400.0 - 200.0 * a.sin())).collect();
|
||||
drag(&mut s, "pencil", &pts, Mods { alt: true, ..Default::default() });
|
||||
let ps = paths(&s);
|
||||
let sp = &ps[0].1.subpaths[0];
|
||||
assert!(sp.closed);
|
||||
let b = ps[0].1.bounds().unwrap();
|
||||
assert!(b.x0 > 149.5 && b.x1 < 450.5 && b.y1 < 400.5, "within the stroke: {b:?}");
|
||||
let (first, last) = (sp.anchors.first().unwrap(), sp.anchors.last().unwrap());
|
||||
assert!(first.p.distance(Point::new(150.0, 400.0)) < 1e-6 && last.p.distance(Point::new(450.0, 400.0)) < 1e-6, "{first:?} {last:?}");
|
||||
assert_eq!((last.h_out, first.h_in), (last.p, first.p), "a straight closing segment");
|
||||
}
|
||||
|
||||
/// #3799: with Shift held, a wavy Pencil drag draws one straight segment at 45° steps from the
|
||||
/// Constrain Angle (here 30°: a drag at about 60° comes out at 75°).
|
||||
#[test]
|
||||
|
||||
@@ -109,6 +109,48 @@ fn saturate_keeps_a_color_it_does_not_change() {
|
||||
assert_eq!(paint(&s, b), cmyk);
|
||||
}
|
||||
|
||||
/// An Edit Colors pass, a Blend or Recolor Artwork that changes nothing records no undo step, and the
|
||||
/// step that redo would bring back stays; one that changes a color still records one.
|
||||
#[test]
|
||||
fn edit_colors_that_change_nothing_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let gray = rect(&mut s, json!("#808080"));
|
||||
let a = rect(&mut s, json!("#ff0000"));
|
||||
let b = rect(&mut s, json!("#808080"));
|
||||
let c = rect(&mut s, json!("#0000ff"));
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
|
||||
s.execute("edit.colors.blendFrontToBack", &json!({})).unwrap();
|
||||
// A step for redo to bring back.
|
||||
s.execute("edit.colors.invert", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let history = |s: &Session| {
|
||||
let h = &s.doc().unwrap().history;
|
||||
(h.undo.len(), h.redo.len())
|
||||
};
|
||||
let before = history(&s);
|
||||
assert_eq!(before.1, 1, "one step to redo");
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let cases = [
|
||||
("edit.colors.invert", json!({"fill": false, "stroke": false})),
|
||||
("edit.colors.saturate", json!({"intensity": 0})),
|
||||
("edit.colors.toRGB", json!({})),
|
||||
("edit.colors.adjustBalance", json!({"mode": "rgb", "r": 0})),
|
||||
("edit.colors.blendFrontToBack", json!({})),
|
||||
("recolor.apply", json!({"map": {"#ff0000": "#ff0000"}})),
|
||||
// The complement of a gray is the same gray.
|
||||
("paint.complement", json!({"ids": [gray.0], "stroke": false})),
|
||||
("addon.color.filter", json!({"effect": "addon.color.invert", "fill": false, "stroke": false})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let v = s.execute(id, &p).unwrap();
|
||||
assert_eq!(v["changed"], 0, "{id}");
|
||||
assert_eq!(history(&s), before, "{id}: no undo step, and redo is kept");
|
||||
assert_eq!(s.doc().unwrap().doc, doc, "{id}: the document is unchanged");
|
||||
}
|
||||
s.execute("edit.colors.invert", &json!({})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0), "a pass that changes colors is one undo step");
|
||||
}
|
||||
|
||||
/// Adjust Color Balance refuses a channel that is not a number, which it would otherwise read as 0.
|
||||
#[test]
|
||||
fn adjust_balance_refuses_a_channel_that_is_not_a_number() {
|
||||
|
||||
@@ -712,6 +712,159 @@ fn type_in_symbol_art_reports_a_gradient_only_when_it_expands() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected type "Hi" at 64 pt whose characters have no paint, with fills and strokes of its own:
|
||||
/// each of `items` is an `appearance.setItem` edit of a new object-level stroke when it has a
|
||||
/// `weight`, else of a new object-level fill; the first `below` of them paint below the characters.
|
||||
fn type_with_own_paint(s: &mut Session, items: &[Value], below: usize) -> NodeId {
|
||||
let t = run(s, "text.create", json!({"x": 25, "y": 110, "text": "Hi", "size": 64}))["id"].as_u64().unwrap();
|
||||
run(s, "select.set", json!({"ids": [t]}));
|
||||
run(s, "paint.setFill", json!({"none": true}));
|
||||
run(s, "paint.setStroke", json!({"none": true}));
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let add = if item.get("weight").is_some() { "appearance.addStroke" } else { "appearance.addFill" };
|
||||
run(s, add, json!({"target": "object"}));
|
||||
let mut p = item.clone();
|
||||
p["index"] = json!(i);
|
||||
run(s, "appearance.setItem", p);
|
||||
}
|
||||
run(s, "appearance.moveItem", json!({"from": "contents", "to": below}));
|
||||
NodeId(t)
|
||||
}
|
||||
|
||||
fn blue_stroke() -> Value {
|
||||
json!({"color": "#0000ff", "weight": 5})
|
||||
}
|
||||
|
||||
fn red_blue() -> Value {
|
||||
json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})
|
||||
}
|
||||
|
||||
/// Create Outlines draws what the type drew: a gradient fill of the type object's own, below its
|
||||
/// characters, keeps one gradient across both glyphs over the layout's box, and its stroke stays
|
||||
/// over them.
|
||||
#[test]
|
||||
fn create_outlines_draws_the_type_objects_own_paint() {
|
||||
let mut s = session();
|
||||
type_with_own_paint(&mut s, &[red_blue(), blue_stroke()], 1);
|
||||
let before = render(&s);
|
||||
run(&mut s, "type.createOutlines", json!({}));
|
||||
let after = render(&s);
|
||||
let n = differing(&before, &after, 24);
|
||||
assert!(n < 40, "{n} pixels differ");
|
||||
assert!(differing(&before, &render(&session()), 24) > 1000, "the type paints something");
|
||||
}
|
||||
|
||||
/// `object.expand.info` reports the type object's own stroke, pattern fill and gradient fill, which
|
||||
/// Object turns into paths of its whole outline: a gradient only when that outline's box needs no
|
||||
/// more runs than are written.
|
||||
#[test]
|
||||
fn type_objects_own_stroke_pattern_and_gradient_are_reported() {
|
||||
let mut s = session();
|
||||
type_with_own_paint(&mut s, &[blue_stroke()], 0);
|
||||
let info = run(&mut s, "object.expand.info", json!({}));
|
||||
assert_eq!((info["object"].clone(), info["stroke"].clone(), info["fill"].clone()), (json!(true), json!(true), json!(false)), "{info}");
|
||||
|
||||
let mut s = session();
|
||||
pattern_rect(&mut s, Rect::new(150.0, 150.0, 190.0, 190.0), false);
|
||||
let t = type_with_own_paint(&mut s, &[json!({"swatch": "Dots"})], 0);
|
||||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||||
let info = run(&mut s, "object.expand.info", json!({}));
|
||||
assert_eq!((info["fill"].clone(), info["gradient"].clone(), info["stroke"].clone()), (json!(true), json!(false), json!(false)), "{info}");
|
||||
|
||||
// 0.4 pt runs across about 80 pt: under the limit; 0.02 pt runs: far over it.
|
||||
for (len, expands) in [(40.0, true), (2.0, false)] {
|
||||
let mut s = session();
|
||||
let gradient = json!({"gradient": {"kind": "linear", "spread": "repeat", "repeats": 100, "start": [25, 100], "end": [25.0 + len, 100],
|
||||
"stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}});
|
||||
type_with_own_paint(&mut s, &[gradient], 0);
|
||||
let info = run(&mut s, "object.expand.info", json!({}));
|
||||
assert_eq!((info["gradient"].clone(), info["fill"].clone()), (json!(expands), json!(expands)), "{len}: {info}");
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
let (gradients, texts) = gradients_and_type(&s);
|
||||
assert_eq!(texts, 0, "{len}: outlined");
|
||||
assert_eq!(gradients == 0, expands, "{len}: {gradients} gradient fills left");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type with only spaces has no outline for its own stroke or pattern fill to go on, so
|
||||
/// `object.expand.info` reports neither.
|
||||
#[test]
|
||||
fn type_without_an_outline_reports_no_own_stroke_or_fill() {
|
||||
let mut s = session();
|
||||
pattern_rect(&mut s, Rect::new(150.0, 150.0, 190.0, 190.0), false);
|
||||
let t = run(&mut s, "text.create", json!({"x": 25, "y": 110, "text": " ", "size": 64}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
run(&mut s, "paint.setFill", json!({"none": true}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
for (i, item) in [json!({"swatch": "Dots"}), blue_stroke()].into_iter().enumerate() {
|
||||
let add = if item.get("weight").is_some() { "appearance.addStroke" } else { "appearance.addFill" };
|
||||
run(&mut s, add, json!({"target": "object"}));
|
||||
let mut p = item;
|
||||
p["index"] = json!(i);
|
||||
run(&mut s, "appearance.setItem", p);
|
||||
}
|
||||
let info = run(&mut s, "object.expand.info", json!({}));
|
||||
assert_eq!(
|
||||
(info["object"].clone(), info["stroke"].clone(), info["fill"].clone(), info["gradient"].clone()),
|
||||
(json!(true), json!(false), json!(false), json!(false)),
|
||||
"{info}"
|
||||
);
|
||||
}
|
||||
|
||||
/// How many objects in the document have a visible stroke with paint and weight.
|
||||
fn painted_strokes(s: &Session) -> usize {
|
||||
let mut n = 0;
|
||||
s.doc().unwrap().doc.walk(|c| {
|
||||
n += usize::from(
|
||||
c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.visible && !st.paint.is_none() && st.width > 0.0)),
|
||||
);
|
||||
});
|
||||
n
|
||||
}
|
||||
|
||||
/// A visible stroke under a topmost stroke with no paint still has something for Stroke to
|
||||
/// outline, on type's own appearance and on a path: `object.expand.info` reports it, and Expand
|
||||
/// outlines it.
|
||||
#[test]
|
||||
fn a_stroke_under_an_unpainted_stroke_is_reported_and_outlined() {
|
||||
let mut s = session();
|
||||
type_with_own_paint(&mut s, &[blue_stroke(), json!({"none": true, "weight": 1})], 0);
|
||||
let mut r = session();
|
||||
run(&mut r, "shape.rectangle", json!({"x": 40, "y": 40, "width": 100, "height": 80}));
|
||||
run(&mut r, "paint.setStroke", json!({"color": "#0000ff"}));
|
||||
run(&mut r, "stroke.set", json!({"weight": 4}));
|
||||
run(&mut r, "appearance.addStroke", json!({"target": "object"}));
|
||||
let top = node(&r, selected(&r).id).appearance.items.len() - 1;
|
||||
run(&mut r, "appearance.setItem", json!({"index": top, "none": true}));
|
||||
for (what, s) in [("type", &mut s), ("path", &mut r)] {
|
||||
let info = run(s, "object.expand.info", json!({}));
|
||||
assert_eq!(info["stroke"], json!(true), "{what}: {info}");
|
||||
run(s, "object.expand", json!({}));
|
||||
assert_eq!(painted_strokes(s), 0, "{what}: the stroke is outlined");
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand keeps the type object's own stroke and gradient fill through Object: Stroke outlines the
|
||||
/// stroke and Fill expands the gradient, and with those options off they stay live on the path of
|
||||
/// the whole outline. The picture stays the same either way.
|
||||
#[test]
|
||||
fn expand_keeps_the_type_objects_own_paint() {
|
||||
for (opts, live) in [(json!({}), false), (json!({"fill": false, "stroke": false}), true)] {
|
||||
let mut s = session();
|
||||
type_with_own_paint(&mut s, &[red_blue(), blue_stroke()], 1);
|
||||
let before = render(&s);
|
||||
run(&mut s, "object.expand", opts.clone());
|
||||
let after = render(&s);
|
||||
assert!(mean_diff(&before, &after) < 2.0, "{opts}: {}", mean_diff(&before, &after));
|
||||
let (gradients, texts) = gradients_and_type(&s);
|
||||
assert_eq!(texts, 0, "{opts}: outlined");
|
||||
assert_eq!(gradients == 1, live, "{opts}: {gradients} gradient fills");
|
||||
let mut strokes = 0;
|
||||
s.doc().unwrap().doc.walk(|n| strokes += usize::from(!n.appearance.stroke_paint().is_none()));
|
||||
assert_eq!(strokes == 1, live, "{opts}: {strokes} strokes");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() {
|
||||
for gradient in [
|
||||
|
||||
@@ -572,6 +572,64 @@ fn a_malformed_required_number_is_not_reported_as_missing() {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
/// A required whole number given as something other than a whole number of 0 or more (`"3"`, `2.5`,
|
||||
/// `-1`, `true`, `[]`, `{}`) is refused as the wrong type, naming its key, not as a missing integer; one
|
||||
/// left out or given as null is still missing. An optional one left out or null keeps its default.
|
||||
/// Neither changes the document or records an undo step.
|
||||
#[test]
|
||||
fn a_malformed_required_integer_is_not_reported_as_missing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let with = |base: &serde_json::Value, key: &str, v: Option<serde_json::Value>| {
|
||||
let mut p = base.clone();
|
||||
let o = p.as_object_mut().unwrap();
|
||||
match v {
|
||||
Some(v) => o.insert(key.into(), v),
|
||||
None => o.remove(key),
|
||||
};
|
||||
p
|
||||
};
|
||||
let refused = |s: &mut Session, cmd: &str, p: &serde_json::Value, want: &str| match s.execute(cmd, p) {
|
||||
Err(EngineError::BadParams { cmd: c, msg }) => assert_eq!((c.as_str(), msg.as_str()), (cmd, want), "{p}"),
|
||||
other => panic!("{cmd} {p}: {other:?}"),
|
||||
};
|
||||
let ends = json!({"sourceDocument": 1, "grab": [0, 0], "at": [0, 0], "ids": [1]});
|
||||
let cases = [
|
||||
("appearance.setItem", json!({"index": 0}), "index", "missing integer `index`"),
|
||||
("path.removeAnchor", json!({"id": 1, "anchor": 0}), "anchor", "missing integer `anchor`"),
|
||||
("path.split", json!({"id": 1, "anchor": 0}), "anchor", "need `anchor` or `segment` + `t`"),
|
||||
("addon.pen.removePoint", json!({"anchor": 0}), "anchor", "missing integer `anchor`"),
|
||||
("edit.dropSelection", ends, "sourceDocument", "missing integer `sourceDocument`"),
|
||||
("document.activate", json!({"index": 0}), "index", "missing index"),
|
||||
("document.move", json!({"to": 0}), "to", "missing `to`: a non-negative integer"),
|
||||
];
|
||||
let malformed = [json!("3"), json!(2.5), json!(-1), json!(true), json!([]), json!({})];
|
||||
for (cmd, base, key, missing) in &cases {
|
||||
for v in &malformed {
|
||||
refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number"));
|
||||
}
|
||||
for p in [with(base, key, None), with(base, key, Some(json!(null)))] {
|
||||
refused(&mut s, cmd, &p, missing);
|
||||
}
|
||||
}
|
||||
// An optional `subpath` given as anything else is refused the same way; left out or null, it is
|
||||
// subpath 0 and the command goes on to read `anchor`.
|
||||
let base = json!({"id": 1, "anchor": "3"});
|
||||
for v in &malformed {
|
||||
refused(&mut s, "path.removeAnchor", &with(&base, "subpath", Some(v.clone())), "`subpath` must be a whole number");
|
||||
}
|
||||
for p in [with(&base, "subpath", None), with(&base, "subpath", Some(json!(null)))] {
|
||||
refused(&mut s, "path.removeAnchor", &p, "`anchor` must be a whole number");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc, doc);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
// A whole JSON number is a whole number.
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
}
|
||||
|
||||
/// A command that takes the keys of one that isn't checked yet isn't checked either.
|
||||
#[test]
|
||||
fn a_reference_to_an_unchecked_command_is_unchecked() {
|
||||
|
||||
@@ -1043,6 +1043,51 @@ fn create_outlines_matches_text_bounds() {
|
||||
assert!(s.doc().unwrap().doc.node(t).is_some());
|
||||
}
|
||||
|
||||
/// The type object's own fills and strokes (not its characters') become paths of its whole outline,
|
||||
/// with ids of their own: those below the Characters row under the glyph paths, the others over
|
||||
/// them, in their order. The group keeps no appearance of its own, and Undo brings the type back.
|
||||
#[test]
|
||||
fn create_outlines_keeps_the_objects_own_fills_and_strokes() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Hi", 64.0);
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
for (cmd, p) in [
|
||||
("appearance.addFill", json!({"target": "object"})),
|
||||
("appearance.setItem", json!({"index": 0, "color": "#ff0000"})),
|
||||
("appearance.addStroke", json!({"target": "object"})),
|
||||
("appearance.setItem", json!({"index": 1, "color": "#0000ff", "weight": 5})),
|
||||
("appearance.addFill", json!({"target": "object"})),
|
||||
("appearance.setItem", json!({"index": 2, "color": "#00ff00", "opacity": 50})),
|
||||
// The red fill paints below the characters; the stroke and the green fill over them.
|
||||
("appearance.moveItem", json!({"from": "contents", "to": 1})),
|
||||
] {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
}
|
||||
let own = node(&s, t).appearance.items.clone();
|
||||
assert_eq!(own.len(), 3);
|
||||
let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap());
|
||||
let g = node(&s, r[0]);
|
||||
assert!(g.appearance.items.is_empty(), "the group paints nothing of its own");
|
||||
let ch = g.children().unwrap();
|
||||
assert_eq!(ch.len(), 4, "a path below, the two glyphs, a path above");
|
||||
let (first, last) = (&ch[0], &ch[3]);
|
||||
assert_eq!(first.appearance.items, own[..1], "the item below the characters");
|
||||
assert_eq!(last.appearance.items, own[1..], "the items over them, in their order");
|
||||
for c in [first, last] {
|
||||
assert!(matches!(c.kind, NodeKind::Path { .. }), "{:?}", c.kind);
|
||||
assert!(c.id != t && c.id != g.id, "a fresh id");
|
||||
}
|
||||
assert!(first.id != last.id);
|
||||
for c in &ch[1..3] {
|
||||
assert_eq!(c.appearance.fill_paint(), Paint::solid(Color::BLACK), "the characters keep their own paint");
|
||||
}
|
||||
// The whole-outline paths cover both glyphs.
|
||||
let glyphs = ch[1].geometric_bounds().unwrap().union(ch[2].geometric_bounds().unwrap());
|
||||
assert!(close(first.geometric_bounds().unwrap(), glyphs, 1e-6), "{:?} vs {glyphs:?}", first.geometric_bounds());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, t).appearance.items, own, "one undo step brings the type back");
|
||||
}
|
||||
|
||||
/// Colour bitmap glyphs (emoji) become embedded images where they sat; glyphs showing one image
|
||||
/// share its pixels.
|
||||
#[test]
|
||||
|
||||
@@ -693,7 +693,6 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
|
||||
("blobBrush", Cursor::BlobBrush),
|
||||
("shaper", Cursor::Shaper),
|
||||
("pencil", Cursor::PencilNew),
|
||||
("smooth", Cursor::Smooth),
|
||||
("pathEraser", Cursor::PathEraser),
|
||||
("join", Cursor::Join),
|
||||
("eraser", Cursor::Eraser),
|
||||
@@ -738,6 +737,27 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
|
||||
}
|
||||
}
|
||||
|
||||
/// With no selected path, the Smooth tool shows Not Allowed, and Use Precise Cursors leaves it so; with a
|
||||
/// selected path, it shows its own pointer, or a crosshair with the preference on.
|
||||
#[test]
|
||||
fn use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer() {
|
||||
use vectorcraft_tools::{Cursor, Mods};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let at = vectorcraft_geom::Point::new(300.0, 300.0);
|
||||
s.select_tool("smooth", ViewInfo::default()).unwrap();
|
||||
for precise in [false, true] {
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap();
|
||||
assert_eq!(s.cursor(at, Mods::default(), ViewInfo::default()), Cursor::NotAllowed, "precise {precise}");
|
||||
}
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
for (precise, c) in [(false, Cursor::Smooth), (true, Cursor::Crosshair)] {
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap();
|
||||
assert_eq!(s.cursor(at, Mods::default(), ViewInfo::default()), c, "precise {precise}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With Alt held while drawing, the Pencil and the Paintbrush show their closing cursors, and Use Precise Cursors
|
||||
/// turns those into a crosshair too.
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,7 @@ fn is_bad_params(r: Result<Value>) -> bool {
|
||||
}
|
||||
|
||||
/// Folder preferences, which a preferences file leaves out.
|
||||
const FOLDERS: [&str; 4] = ["fontsFolder", "pluginsFolder", "recoveryFolder", "templatesFolder"];
|
||||
const FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
|
||||
|
||||
/// A preferences file holding `prefs`, as `prefs.export` writes one.
|
||||
fn file(prefs: Value) -> String {
|
||||
@@ -46,7 +46,9 @@ fn in_file(s: &Session) -> Value {
|
||||
fn exported_preferences_import_unchanged() {
|
||||
let mut s = changed();
|
||||
s.prefs.fonts_folder = "/fonts/here".into();
|
||||
s.prefs.scratch_primary = "/scratch/here".into();
|
||||
let r = s.execute("prefs.export", &json!({})).unwrap();
|
||||
assert!(!r["data"].as_str().unwrap().contains("/scratch/here"), "a scratch disk folder is not written");
|
||||
let text = r["data"].as_str().unwrap().to_string();
|
||||
let written: Value = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!((written["format"].as_str(), written["version"].as_u64()), (Some(PREFS_FORMAT), Some(1)));
|
||||
@@ -71,6 +73,7 @@ fn exported_preferences_import_unchanged() {
|
||||
assert_eq!(t.doc().unwrap().doc.units, Unit::Millimeters, "the document's units stay as they are");
|
||||
assert_eq!(t.doc().unwrap().history.undo.len(), undo, "no undo step");
|
||||
assert_eq!(t.prefs.fonts_folder, "", "folders are not carried over");
|
||||
assert_eq!(t.prefs.scratch_primary, "Startup", "nor is a scratch disk");
|
||||
|
||||
// Through a file, too.
|
||||
let path = std::env::temp_dir().join(format!("vc-prefs-{}.json", std::process::id())).to_string_lossy().to_string();
|
||||
@@ -96,6 +99,10 @@ fn a_hand_written_file_sets_what_it_names_and_leaves_the_rest() {
|
||||
assert_eq!(s.prefs.keyboard_increment, 3.0);
|
||||
assert_eq!(s.prefs.guide_color, "#abcdef");
|
||||
assert_eq!(s.prefs.history_states, 77, "a preference the file leaves out keeps its value");
|
||||
// A scratch disk choice names no folder, and is read.
|
||||
let choices = file(json!({"scratchPrimary": "None", "scratchSecondary": "Startup"}));
|
||||
assert_eq!(s.execute("prefs.import", &json!({"data": choices})).unwrap(), json!({"imported": 2}));
|
||||
assert_eq!((s.prefs.scratch_primary.as_str(), s.prefs.scratch_secondary.as_str()), ("None", "Startup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -119,6 +126,8 @@ fn a_bad_preferences_file_is_refused_and_nothing_changes() {
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "historyStates": 5000}))}), "`historyStates` must be a whole number between 5 and 1000"),
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "eyedropper": 5}))}), "`eyedropper` must be an object"),
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "fontsFolder": "/fonts"}))}), "`fontsFolder` names a folder"),
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "scratchPrimary": "/scratch"}))}), "`scratchPrimary` names a folder"),
|
||||
(json!({"data": file(json!({"uiAccent": "blue", "scratchSecondary": " scratch "}))}), "`scratchSecondary` names a folder"),
|
||||
(json!({"data": too_large}), "larger than 1 MB"),
|
||||
(json!({"data": 5}), "`data` must be a string"),
|
||||
(json!({"path": "/a.json", "data": file(json!({"uiAccent": "blue"}))}), "give `path` or `data`"),
|
||||
|
||||
@@ -1075,6 +1075,146 @@ fn deleting_a_global_swatch_unlinks_art_that_keeps_its_colour() {
|
||||
assert_eq!(fill_of(&s, a), linked("#2a6fb0", "Brand"));
|
||||
}
|
||||
|
||||
/// A session whose default fill and stroke for new art are linked to the global swatch `Brand`
|
||||
/// (`#2a6fb0`), in color group `G`, beside the global swatch `Other` (`#ff8800`).
|
||||
fn brand_defaults_session() -> Session {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true}));
|
||||
run(&mut s, "swatch.newGroup", json!({"name": "G", "swatches": ["Brand"]}));
|
||||
run(&mut s, "swatch.new", json!({"name": "Other", "color": "#ff8800", "global": true}));
|
||||
run(&mut s, "select.set", json!({"ids": []}));
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Brand"}));
|
||||
run(&mut s, "paint.setStroke", json!({"swatch": "Brand"}));
|
||||
assert_eq!(s.paint.fill, linked("#2a6fb0", "Brand"));
|
||||
s
|
||||
}
|
||||
|
||||
fn fill_and_stroke(s: &Session, id: NodeId) -> (Paint, Paint) {
|
||||
let a = &doc(s).node(id).unwrap().appearance;
|
||||
(a.fill_paint(), a.stroke_paint())
|
||||
}
|
||||
|
||||
/// Run `steps` and then draw a rectangle, once one command at a time and once as one
|
||||
/// `command.batch`, and check that both leave the same default paints and give the rectangle drawn
|
||||
/// in the batch, and one drawn after it, the same fill and stroke. Returns the batch's rectangle's
|
||||
/// fill and stroke.
|
||||
fn batch_matches_steps(steps: &[(&str, Value)]) -> (Paint, Paint) {
|
||||
let shape = ("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
||||
let mut one_by_one = brand_defaults_session();
|
||||
for (id, p) in steps {
|
||||
run(&mut one_by_one, id, p.clone());
|
||||
}
|
||||
let single = rect(&mut one_by_one);
|
||||
let mut batched = brand_defaults_session();
|
||||
let commands: Vec<Value> = steps.iter().chain([&shape]).map(|(id, p)| json!({"command": id, "params": p})).collect();
|
||||
let r = run(&mut batched, "command.batch", json!({ "commands": commands }));
|
||||
let in_batch = NodeId(r["results"][steps.len()]["id"].as_u64().unwrap());
|
||||
assert_eq!(batched.paint.fill, one_by_one.paint.fill, "the default fill after {steps:?}");
|
||||
assert_eq!(batched.paint.stroke, one_by_one.paint.stroke, "the default stroke after {steps:?}");
|
||||
let expected = fill_and_stroke(&one_by_one, single);
|
||||
assert_eq!(fill_and_stroke(&batched, in_batch), expected, "art drawn in the batch after {steps:?}");
|
||||
let later = rect(&mut batched);
|
||||
assert_eq!(fill_and_stroke(&batched, later), expected, "art drawn after the batch after {steps:?}");
|
||||
expected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatch_steps_in_a_batch_change_the_new_art_paints_as_commands_do() {
|
||||
let blue = Paint::solid(Color::from_hex("#2a6fb0").unwrap());
|
||||
assert_eq!(batch_matches_steps(&[("swatch.delete", json!({"name": "Brand"}))]), (blue.clone(), blue));
|
||||
let link = linked("#2a6fb0", "Brand");
|
||||
assert_eq!(batch_matches_steps(&[("swatch.delete", json!({"name": "Brand", "unlink": false}))]), (link.clone(), link));
|
||||
let signal = linked("#00ff00", "Signal");
|
||||
let edit = ("swatch.edit", json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"}));
|
||||
assert_eq!(batch_matches_steps(&[edit]), (signal.clone(), signal));
|
||||
let other = linked("#ff8800", "Other");
|
||||
assert_eq!(batch_matches_steps(&[("swatch.merge", json!({"names": ["Other", "Brand"]}))]), (other.clone(), other));
|
||||
let gray = linked("#ababab", "Brand");
|
||||
assert_eq!(batch_matches_steps(&[("swatch.editGroup", json!({"group": "G", "colors": ["#ababab"]}))]), (gray.clone(), gray));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_of_a_swatch_edit_put_back_the_new_art_paints() {
|
||||
let edit = json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"});
|
||||
let delete = json!({"name": "Brand"});
|
||||
for (id, p, fill) in
|
||||
[("swatch.edit", &edit, linked("#00ff00", "Signal")), ("swatch.delete", &delete, Paint::solid(Color::from_hex("#2a6fb0").unwrap()))]
|
||||
{
|
||||
for batch in [false, true] {
|
||||
let mut s = brand_defaults_session();
|
||||
let before = s.paint.clone();
|
||||
if batch {
|
||||
run(&mut s, "command.batch", json!({"commands": [{"command": id, "params": p}]}));
|
||||
} else {
|
||||
run(&mut s, id, p.clone());
|
||||
}
|
||||
assert_eq!(s.paint.fill, fill, "{id}, batch: {batch}");
|
||||
let after = s.paint.clone();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.paint, before, "Undo of {id}, batch: {batch}");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.paint, after, "Redo of {id}, batch: {batch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatch_edits_reach_the_paints_of_the_new_art_template() {
|
||||
let mut s = brand_defaults_session();
|
||||
s.paint.appearance = Some(vectorcraft_doc::Appearance::basic(linked("#2a6fb0", "Brand"), linked("#2a6fb0", "Brand"), 1.0));
|
||||
run(&mut s, "swatch.edit", json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"}));
|
||||
let template = |s: &Session| -> Vec<Paint> {
|
||||
s.paint
|
||||
.appearance
|
||||
.iter()
|
||||
.flat_map(|a| a.items.iter())
|
||||
.map(|it| match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.clone(),
|
||||
vectorcraft_doc::AppearanceItem::Stroke(k) => k.paint.clone(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(template(&s), [linked("#00ff00", "Signal"), linked("#00ff00", "Signal")]);
|
||||
run(&mut s, "swatch.delete", json!({"name": "Signal"}));
|
||||
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
|
||||
assert_eq!(template(&s), [green.clone(), green]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_batch_puts_back_the_new_art_paints_a_swatch_step_changed() {
|
||||
let mut s = brand_defaults_session();
|
||||
let before = s.paint.clone();
|
||||
let steps = json!([
|
||||
{"command": "swatch.edit", "params": {"name": "Brand", "newName": "Signal", "color": "#00ff00"}},
|
||||
{"command": "no.such.command", "params": {}},
|
||||
]);
|
||||
assert!(s.execute("command.batch", &json!({ "commands": steps })).is_err());
|
||||
assert_eq!(s.paint, before);
|
||||
assert_eq!(doc(&s).swatch("Brand").unwrap().paint.color(), Some(Color::from_hex("#2a6fb0").unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceling_a_swatch_options_preview_keeps_the_new_art_paints() {
|
||||
let mut s = brand_defaults_session();
|
||||
let before = s.paint.clone();
|
||||
s.begin_interaction("Swatch Options").unwrap();
|
||||
s.preview("swatch.edit", &json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"})).unwrap();
|
||||
s.cancel_interaction().unwrap();
|
||||
assert_eq!(s.paint, before);
|
||||
assert!(doc(&s).swatch("Signal").is_none());
|
||||
// A previewed batch that is canceled puts them back too, and one that is accepted keeps them.
|
||||
let steps = json!([{"command": "swatch.edit", "params": {"name": "Brand", "newName": "Signal", "color": "#00ff00"}}]);
|
||||
s.begin_interaction("Batch Preview").unwrap();
|
||||
s.preview("command.batch", &json!({ "commands": steps })).unwrap();
|
||||
s.cancel_interaction().unwrap();
|
||||
assert_eq!(s.paint, before);
|
||||
s.begin_interaction("Batch Preview").unwrap();
|
||||
s.preview("command.batch", &json!({ "commands": steps })).unwrap();
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(s.paint.fill, linked("#00ff00", "Signal"));
|
||||
assert_eq!(s.paint.stroke, linked("#00ff00", "Signal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_several_swatches_and_groups() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -20,9 +20,18 @@ fn close(a: Rect, b: Rect) -> bool {
|
||||
/// A 100 × 100 frame with a 10 × 10 square in its top-left corner and one in its center, made
|
||||
/// into symbol "Panel" with 9-slice guides on, its instance selected at its natural size.
|
||||
fn panel_symbol() -> (Session, NodeId) {
|
||||
panel_symbol_filled(None)
|
||||
}
|
||||
|
||||
/// [`panel_symbol`] with the frame filled with `fill` when there is one.
|
||||
fn panel_symbol_filled(fill: Option<vectorcraft_color::Paint>) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let ids = [rect(&mut s, 50.0, 50.0, 100.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0, 10.0)];
|
||||
if let Some(fill) = fill {
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
doc.node_mut(ids[0]).unwrap().appearance = vectorcraft_doc::Appearance::basic(fill, vectorcraft_color::Paint::None, 0.0).into();
|
||||
}
|
||||
s.select(|_, sel| sel.set(ids)).unwrap();
|
||||
let r = s.execute("symbol.new", &json!({"name": "Panel", "nineSlice": true})).unwrap();
|
||||
assert_eq!(r["nineSlice"], json!([0.25, 0.25, 0.25, 0.25]));
|
||||
@@ -350,3 +359,39 @@ fn guides_outside_the_art_survive_saving_and_reopening_a_native_file() {
|
||||
assert!(back.symbol_slice_map(symbol, *xf).is_none(), "{bad}: scaled as a whole");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`panel_symbol`] with a gradient on the frame, so that the frame is drawn in a clip group for
|
||||
/// each cell, its instance resized to 200 × 150 and turned 20°.
|
||||
fn turned_gradient_panel() -> (Session, NodeId) {
|
||||
let gradient = vectorcraft_color::Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
|
||||
let (mut s, id) = panel_symbol_filled(Some(gradient));
|
||||
s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 200, "height": 150, "reference": 0})).unwrap();
|
||||
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// The bounding box angles of the objects under the art that replaced an instance, and whether
|
||||
/// that art holds a clip group.
|
||||
fn expanded_angles(s: &Session, art: NodeId) -> (Vec<f64>, bool) {
|
||||
let (mut angles, mut clips) = (vec![], false);
|
||||
s.doc().unwrap().doc.node(art).unwrap().walk(&mut |c| {
|
||||
angles.push(c.bbox_angle);
|
||||
clips |= matches!(c.kind, NodeKind::Group { clip: true, .. });
|
||||
});
|
||||
(angles, clips)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_link_turns_the_cells_of_a_sliced_instance_with_its_box() {
|
||||
for reset in [false, true] {
|
||||
let (mut s, _) = turned_gradient_panel();
|
||||
if reset {
|
||||
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
|
||||
}
|
||||
let r = s.execute("symbol.breakLink", &json!({})).unwrap();
|
||||
let (angles, clips) = expanded_angles(&s, NodeId(r["ids"][0].as_u64().unwrap()));
|
||||
assert!(clips, "the frame is drawn in clip groups");
|
||||
let want = if reset { 0.0 } else { 20.0 };
|
||||
assert!(angles.iter().all(|a| (a - want).abs() < 1e-6), "reset {reset}: {angles:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2137,8 +2137,9 @@ fn datasets_csv_reads_windows_1252_files() {
|
||||
}
|
||||
|
||||
/// A CSV that starts with a UTF-16 byte-order mark is read as UTF-16, and
|
||||
/// one that isn't valid UTF-16 is refused. A file with NUL bytes and no byte-order mark is refused.
|
||||
/// A text value holding NUL imports back from the `csv` that `dataset.export` returns.
|
||||
/// one that isn't valid UTF-16 is refused. A file with NUL bytes and no byte-order mark that isn't
|
||||
/// UTF-16 is refused. A text value holding NUL imports back from the `csv` that `dataset.export`
|
||||
/// returns.
|
||||
#[test]
|
||||
fn datasets_csv_reads_utf16_files() {
|
||||
let mut s = session();
|
||||
@@ -2160,7 +2161,7 @@ fn datasets_csv_reads_utf16_files() {
|
||||
assert!(e.contains("CSV is not UTF-16"), "{e}");
|
||||
let e = import(&mut s, &[b"\xff\xfe".as_slice(), "City\nAB,".as_bytes(), b"\x00\xd8"].concat()).unwrap_err().to_string();
|
||||
assert!(e.contains("CSV is not UTF-16"), "{e}");
|
||||
let e = import(&mut s, &utf16(b"", "City\nx\n", true)).unwrap_err().to_string();
|
||||
let e = import(&mut s, b"City\nx\0\n").unwrap_err().to_string();
|
||||
assert!(e.contains("CSV holds NUL bytes"), "{e}");
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("José"), "a refused file changes nothing");
|
||||
s.execute("dataset.set", &json!({"name": "Data Set 1", "values": {"City": "a\u{0}b"}})).unwrap();
|
||||
@@ -2170,6 +2171,63 @@ fn datasets_csv_reads_utf16_files() {
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("a\u{0}b"));
|
||||
}
|
||||
|
||||
/// A CSV in UTF-16 without a byte-order mark, with a NUL byte in at least half of its units, is
|
||||
/// read in the byte order that reads a line break: in either order, with characters whose bytes
|
||||
/// read as a line break, a quote or NUL, and when its bytes are also valid UTF-8. Files with fewer
|
||||
/// NUL bytes, that read a line break in both orders, that aren't valid UTF-16 in it, that hold a
|
||||
/// NUL character or that have an odd length are refused.
|
||||
#[test]
|
||||
fn datasets_csv_reads_utf16_files_without_a_byte_order_mark() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "City", "kind": "text"})).unwrap();
|
||||
let import = |s: &mut Session, bytes: &[u8]| s.execute("dataset.import", &json!({"dataBase64": vectorcraft_format::base64_encode(bytes)}));
|
||||
let value = |s: &Session, name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("City")).cloned();
|
||||
let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into()));
|
||||
let utf16 = |text: &str, le: bool| -> Vec<u8> { text.encode_utf16().flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }).collect() };
|
||||
import(&mut s, &utf16("City\r\nParis\r\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("Paris"));
|
||||
import(&mut s, &utf16("City\nZoë 😀\n", false)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("Zoë 😀"));
|
||||
// U+4E00's low byte is NUL, U+010A holds a `\n` byte and U+2200 a `"` byte; all three files
|
||||
// are valid UTF-8 too.
|
||||
import(&mut s, &utf16("City\n一\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("一"));
|
||||
import(&mut s, &utf16("City\nĊ\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("Ċ"));
|
||||
import(&mut s, &utf16("City\n∀∀∀\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("∀∀∀"));
|
||||
// A file with no line break has no data rows in either byte order.
|
||||
for bytes in [utf16("City", true), utf16("City", false), b"\0aa\0".to_vec()] {
|
||||
let e = import(&mut s, &bytes).unwrap_err().to_string();
|
||||
assert!(e.contains("CSV has a header but no data rows"), "{e}");
|
||||
}
|
||||
import(&mut s, &utf16("City\tName\n一二三丁目\tKai\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("一二三丁目"));
|
||||
// Most units hold their NUL byte where a big-endian file would (U+4E00); the line breaks
|
||||
// give the byte order.
|
||||
import(&mut s, &utf16("City\n一一一一一一一一一一二\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("一一一一一一一一一一二"));
|
||||
// Windows-1252 and UTF-8 text with stray NUL bytes (the first with `\n\0` units), UTF-16 with
|
||||
// fewer than half of its units holding a NUL byte, and, last, two files that read a line break
|
||||
// in both byte orders (U+0D00 reads as `\r` and U+0A00 as `\n` big-endian).
|
||||
let refused: [&[u8]; 9] = [
|
||||
b"Caf\xe9\n\0Lyon\n\0",
|
||||
b"Cit\0\n",
|
||||
b"City\nx\0\n",
|
||||
&utf16("City\n丁丁丁丁丁丁丁丁丁丁\n", true),
|
||||
&utf16("City\na\u{0}\n", true),
|
||||
&[utf16("City\n", true), vec![0x00, 0xD8], utf16("\n", true)].concat(),
|
||||
&[utf16("City\n", false), vec![0xDC, 0x00], utf16("\n", false)].concat(),
|
||||
&utf16("City\n\u{0D00}\u{0D00}\u{0D00}\n", true),
|
||||
&utf16("City\u{0A00}\nParis\n", true),
|
||||
];
|
||||
for bytes in refused {
|
||||
let e = import(&mut s, bytes).unwrap_err().to_string();
|
||||
assert!(e.contains("CSV holds NUL bytes"), "{bytes:?}: {e}");
|
||||
}
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("一一一一一一一一一一二"), "a refused file changes nothing");
|
||||
}
|
||||
|
||||
/// A data set of [`library`]: its name, and each variable's name with its paragraphs.
|
||||
type LibrarySet<'a> = (&'a str, &'a [(&'a str, &'a [&'a str])]);
|
||||
|
||||
|
||||
@@ -1164,6 +1164,12 @@ impl Session {
|
||||
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
|
||||
}
|
||||
|
||||
/// Does the active tool take `key`, pressed with `mods` held, ahead of the shortcuts bound to it
|
||||
/// (see `Tool::claims_key_with`)?
|
||||
pub fn tool_claims_key_with(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> bool {
|
||||
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key_with(cx, key, mods))
|
||||
}
|
||||
|
||||
/// Has the active tool started something that Escape ends while no button is pressed (see
|
||||
/// `Tool::ends_on_escape`)?
|
||||
pub fn tool_ends_on_escape(&mut self, view: ViewInfo) -> bool {
|
||||
|
||||
@@ -7488,3 +7488,33 @@ proptest! {
|
||||
prop_assert!(!notes.iter().any(|n| n.contains("art this can't read")), "the records aren't art: {:?}", notes);
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph in `.ai` editing data whose records read whole (version 7 specification, appendix A), of
|
||||
/// a table `rows` × `cols` (its label row and column included): a series group tagged by its
|
||||
/// column, and the axes on the plot's edges.
|
||||
fn live_graph_text(rows: &str, cols: &str) -> String {
|
||||
format!(
|
||||
"%_Gs\n%_10 90 110 10 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\
|
||||
%_1 () Ga\n%_0 13 0 0 0 0 0 () GA\n%_2 () Ga\n%_0 14 0 0 0 0 0 () GA\n%_4 () Ga\n%_0 14 0 0 0 0 0 () GA\n\
|
||||
%_{rows} {cols} 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n\
|
||||
u\nu\n0 0 0 1 k\n20 10 m\n20 50 L\n40 50 L\n40 10 L\nf\nU\n%_2 1 0 0 Go\nu\n0 0 0 1 K\n10 90 m\n10 10 L\n110 10 L\nS\nU\nU\n%_GS\n"
|
||||
)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// A `.ai` graph that reads whole, then cut or damaged, or with a table of any size (zero, past
|
||||
/// the limits, past what memory holds), never panics, and its drawn art always comes in.
|
||||
#[test]
|
||||
fn ai_live_graph_never_panics(cut in 0usize..800, edits in prop::collection::vec((0usize..800, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '%', '_', 'G', 'c', 'C', 'o', 's', 'S', 'u', 'U'])), 0..12), rows in prop_oneof![Just(2u64), 0u64..20_000, Just(u64::MAX)], cols in prop_oneof![Just(2u64), 0u64..400, Just(4_000_000_000u64)]) {
|
||||
let text = mutate_text(&live_graph_text("2", "2"), cut, &edits);
|
||||
survive("mutated live .ai graph", || Some(vectorcraft_eps::layered_ai(&ai_private(&text), Document::new(100.0, 100.0), vec![], false, true, 1.0).0))?;
|
||||
let sized = live_graph_text(&rows.to_string(), &cols.to_string());
|
||||
let (doc, notes, read) = vectorcraft_eps::layered_ai(&ai_private(&sized), Document::new(4000.0, 200.0), vec![], false, true, 1.0);
|
||||
prop_assert!(read, "{:?}", notes);
|
||||
let mut paths = 0;
|
||||
doc.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
|
||||
prop_assert_eq!(paths, 2, "{:?}", notes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "VectorCraft Encapsulated PostScript: a hand-written EPS writer (P
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-eps = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
|
||||
@@ -3255,7 +3255,8 @@ fn images_are_written_again_as_the_same_bytes() {
|
||||
|
||||
/// The program of `doc` with LF line ends, and each image's samples (from after `XI` and its line
|
||||
/// end to `%%EndData`, a `%_` before it kept) and each preview's rows (from after `32 17 ` to the
|
||||
/// line end before `%AI26_EndPlacedObjectPreview`) shown as `<n bytes>`.
|
||||
/// line end before `%AI26_EndPlacedObjectPreview` or `%_%AI26_EndPlacedObjectPreview`) shown as
|
||||
/// `<n bytes>`.
|
||||
fn program_shown(doc: &Document) -> String {
|
||||
shown(&export_ai_editing(doc, &opts()).unwrap().program)
|
||||
}
|
||||
@@ -3267,10 +3268,13 @@ fn shown(p: &[u8]) -> String {
|
||||
let mut at = 0;
|
||||
loop {
|
||||
let xi = find(at, b"\r\nXI\r\n").map(|i| (i + 6, b"%%EndData".as_slice()));
|
||||
let pv = find(at, b" 32 17 ").map(|i| (i + 7, b"\r\n%AI26_EndPlacedObjectPreview".as_slice()));
|
||||
let pv = find(at, b" 32 17 ").map(|i| (i + 7, b"%AI26_EndPlacedObjectPreview".as_slice()));
|
||||
let Some((start, end)) = [xi, pv].into_iter().flatten().min_by_key(|(s, _)| *s) else { break };
|
||||
let mut stop = find(start, end).unwrap();
|
||||
if end.starts_with(b"%%") && p.get(stop - 2..stop) == Some(b"%_") {
|
||||
if p.get(stop - 2..stop) == Some(b"%_") {
|
||||
stop -= 2;
|
||||
}
|
||||
if end.starts_with(b"%AI26") && p.get(stop - 2..stop) == Some(b"\r\n") {
|
||||
stop -= 2;
|
||||
}
|
||||
out.extend_from_slice(&p[at..start]);
|
||||
@@ -7354,18 +7358,147 @@ fn an_image_on_hidden_lines_does_not_come_in_beside_its_look() {
|
||||
assert_eq!(art[0].name.as_deref(), Some("Photo"));
|
||||
}
|
||||
|
||||
/// A linked image, an image with an opacity mask, and an image with a Warp and another effect are
|
||||
/// written as their drawn art, with no art style.
|
||||
/// [`warped_photo`] linked to `/Users/someone/Job/art/photo.png`, in folder `art` next to the
|
||||
/// document's.
|
||||
fn linked_warped_photo(doc: &mut Document, effects: Vec<vectorcraft_doc::Effect>) -> Node {
|
||||
let mut n = warped_photo(doc, effects);
|
||||
if let NodeKind::Image(im) = &mut n.kind {
|
||||
let mut link = vectorcraft_doc::LinkInfo::new("/Users/someone/Job/art/photo.png");
|
||||
link.relative = Some("art/photo.png".into());
|
||||
im.link = Some(link);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// A linked image with one Warp is written with an art style of that Warp, its bent pieces as its
|
||||
/// drawn look and the image itself as a placed file on `%_` lines, with its path, folder and
|
||||
/// preview: it reads back as the linked image, its pixels, placement, name, opacity and link as
|
||||
/// they were, with its Warp live and its settings kept.
|
||||
#[test]
|
||||
fn a_linked_image_with_a_warp_reads_back_linked_with_its_warp_live() {
|
||||
// GUARDS: a linked image with a Warp was written as its drawn pieces alone, and read back
|
||||
// without its Warp or its link.
|
||||
let params = serde_json::json!({"bend": 40.0});
|
||||
let doc = photo_doc(|d| linked_warped_photo(d, vec![fx("warp.arc", params.clone())]));
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
let text = program_shown(&doc);
|
||||
assert!(text.contains("\n%_%AI5_BeginPlace\n"), "{text}");
|
||||
assert!(text.contains("\n%_(/Users/someone/Job/art/photo.png)`\n"), "{text}");
|
||||
assert!(text.contains("\n%_(art/)\n"), "{text}");
|
||||
assert!(text.contains("\n%_8 8 32 32 17 <256 bytes>\n"), "{text}");
|
||||
assert!(text.contains("\n%_%AI5_EndPlace\n") && text.contains("\n1 (Anon 1) XW\n"), "{text}");
|
||||
assert!(!text.contains("BeginRaster\n%_"), "no embedded copy: {text}");
|
||||
let back = read_back(&doc);
|
||||
let art = back.layers[0].children().unwrap();
|
||||
assert_eq!(art.len(), 1, "{:#?}", lines(&back));
|
||||
let (mut want, mut got) = (vec![], vec![]);
|
||||
pictures(&doc, doc.layers[0].children().unwrap(), "", &mut want);
|
||||
pictures(&back, art, "", &mut got);
|
||||
assert_eq!(got, want);
|
||||
let [warp] = art[0].appearance.effects.as_slice() else { panic!("{:?}", art[0].appearance) };
|
||||
assert_eq!(warp.id, "warp.arc");
|
||||
let (w, g) = (vectorcraft_effects::merged_params("warp.arc", ¶ms), vectorcraft_effects::merged_params(&warp.id, &warp.params));
|
||||
for key in ["bend", "horizontal", "vertical"] {
|
||||
assert!((g[key].as_f64().unwrap() - w[key].as_f64().unwrap()).abs() < 6e-3, "{key}: {g} for {w}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A linked image with one Warp larger than a placed file's preview: its copy's preview and its drawn
|
||||
/// look's pieces (placed files of the same image) hold the same smaller preview, so fine detail
|
||||
/// reads back with the Warp live, the link kept and the preview's pixels, as a linked image without
|
||||
/// a Warp does.
|
||||
#[test]
|
||||
fn a_large_linked_image_with_a_warp_reads_back_from_its_preview() {
|
||||
// GUARDS: a linked image with a Warp was written as its drawn pieces alone.
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let checks =
|
||||
image::RgbaImage::from_fn(600, 300, |x, y| if (x + y) % 2 == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) });
|
||||
let mut link = vectorcraft_doc::LinkInfo::new("/Users/someone/Job/art/checks.png");
|
||||
link.relative = Some("art/checks.png".into());
|
||||
let xf = Affine::new([0.25, 0.0, 0.0, 0.25, 20.0, 10.0]);
|
||||
let mut n = image_node(&mut doc, ImageBlob::png(png_of(checks.into())), (600, 300), xf, Some(link));
|
||||
n.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into();
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
let text = program_shown(&doc);
|
||||
assert!(text.contains("\n%_256 128 1024 32 17 <131072 bytes>\n"), "the copy's preview");
|
||||
assert!(text.contains("\n256 128 1024 32 17 <131072 bytes>\n"), "the look's pieces' previews");
|
||||
let back = read_back(&doc);
|
||||
let art = back.layers[0].children().unwrap();
|
||||
assert_eq!(art.len(), 1, "{:#?}", lines(&back));
|
||||
let NodeKind::Image(im) = &art[0].kind else { panic!("{:?}", art[0].kind) };
|
||||
assert_eq!((im.width, im.height), (256, 128));
|
||||
assert_eq!(
|
||||
im.link.as_ref().map(|l| (l.path.as_str(), l.relative.as_deref())),
|
||||
Some(("/Users/someone/Job/art/checks.png", Some("art/checks.png")))
|
||||
);
|
||||
assert_eq!(art[0].appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["warp.arc"]);
|
||||
}
|
||||
|
||||
/// A hidden or locked image with one Warp, embedded or linked, keeps its state, its link and its
|
||||
/// live Warp.
|
||||
#[test]
|
||||
fn a_hidden_or_locked_image_with_a_warp_keeps_its_state() {
|
||||
// GUARDS: a linked image with a Warp was written as its drawn pieces alone, and a hidden image's
|
||||
// Warp was compared with a look that drew nothing, so it came in as its drawn pieces.
|
||||
for (what, make) in [("embedded", warped_photo as fn(&mut Document, Vec<vectorcraft_doc::Effect>) -> Node), ("linked", linked_warped_photo)] {
|
||||
for (shown, locked) in [(true, true), (false, false), (false, true)] {
|
||||
let doc = photo_doc(|d| {
|
||||
let mut n = make(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))]);
|
||||
(n.visible, n.locked) = (shown, locked);
|
||||
n
|
||||
});
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{what} {shown} {locked}: {:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let art = back.layers[0].children().unwrap();
|
||||
assert_eq!(art.len(), 1, "{what} {shown} {locked}: {:#?}", lines(&back));
|
||||
assert_eq!(images_of(&back).len(), 1, "{what} {shown} {locked}");
|
||||
assert_eq!(images_of(&back), images_of(&doc), "{what} {shown} {locked}");
|
||||
assert_eq!(art[0].appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["warp.arc"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A linked image with one Warp in an opacity mask's art keeps its live Warp; its copy on `%_` lines
|
||||
/// is embedded, as linked images in masks are, with that warning.
|
||||
#[test]
|
||||
fn a_linked_image_with_a_warp_in_a_mask_is_embedded_with_its_warp_live() {
|
||||
// GUARDS: the placed file of a styled image written in an opacity mask's art.
|
||||
let doc = photo_doc(|d| {
|
||||
let art = linked_warped_photo(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))]);
|
||||
let mut square =
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 10.0, 100.0, 90.0)), Appearance::basic(solid(Color::BLACK), Paint::None, 0.0));
|
||||
square.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
|
||||
square
|
||||
});
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::LINKS_EMBEDDED]);
|
||||
let text = program_shown(&doc);
|
||||
assert!(!text.contains("BeginPlace") && text.contains("1 (Anon 1) XW"), "{text}");
|
||||
let back = read_back(&doc);
|
||||
let mask = back.layers[0].children().unwrap()[0].extras().mask.as_ref().map(|m| m.art.clone()).expect("the mask");
|
||||
let NodeKind::Image(im) = &mask.kind else { panic!("{:?}", mask.kind) };
|
||||
assert!(im.link.is_none());
|
||||
assert_eq!(mask.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["warp.arc"]);
|
||||
}
|
||||
|
||||
/// An image with an opacity mask and an image with a Warp and another effect are written as their
|
||||
/// drawn art, with no art style, linked or embedded.
|
||||
#[test]
|
||||
fn other_warped_images_are_written_as_their_drawn_art() {
|
||||
let arc = || fx("warp.arc", serde_json::json!({"bend": 40.0}));
|
||||
let linked = photo_doc(|d| {
|
||||
let mut n = warped_photo(d, vec![arc()]);
|
||||
if let NodeKind::Image(im) = &mut n.kind {
|
||||
im.link = Some(vectorcraft_doc::LinkInfo::new("/art/photo.png"));
|
||||
}
|
||||
let linked_masked = photo_doc(|d| {
|
||||
let mut n = linked_warped_photo(d, vec![arc()]);
|
||||
let white =
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 10.0, 100.0, 90.0)), Appearance::basic(solid(Color::WHITE), Paint::None, 0.0));
|
||||
n.extras_mut().mask = Some(Box::new(OpacityMask::new(white, false)));
|
||||
n
|
||||
});
|
||||
let linked_stacked = photo_doc(|d| linked_warped_photo(d, vec![arc(), fx("stylize.feather", serde_json::json!({"radius": 2.0}))]));
|
||||
let masked = photo_doc(|d| {
|
||||
let mut n = warped_photo(d, vec![arc()]);
|
||||
let white =
|
||||
@@ -7374,7 +7507,7 @@ fn other_warped_images_are_written_as_their_drawn_art() {
|
||||
n
|
||||
});
|
||||
let stacked = photo_doc(|d| warped_photo(d, vec![arc(), fx("stylize.feather", serde_json::json!({"radius": 2.0}))]));
|
||||
for (what, doc) in [("linked", linked), ("masked", masked), ("stacked", stacked)] {
|
||||
for (what, doc) in [("linked masked", linked_masked), ("linked stacked", linked_stacked), ("masked", masked), ("stacked", stacked)] {
|
||||
assert!(!program_shown(&doc).contains("1 (Anon"), "{what}");
|
||||
let back = read_back(&doc);
|
||||
let art = back.layers[0].children().unwrap();
|
||||
@@ -7582,9 +7715,10 @@ fn a_linked_object_past_the_object_limit_warns_that_its_link_is_left_out() {
|
||||
}
|
||||
|
||||
/// A link to a graphic style with Twist is kept where the reader reads Twist: on a curve, a curved
|
||||
/// compound path or a group with nothing stroked up to a quarter turn, and on a path with straight
|
||||
/// segments at any angle. A stroked curve, a group with a stroked member or a guide, or a curve,
|
||||
/// compound path or group twisted further, is written without the link and warns.
|
||||
/// compound path or a group with nothing stroked up to a quarter turn, and on a path or compound path
|
||||
/// with straight segments up to half a turn. A stroked curve, a group with a stroked member or a
|
||||
/// guide, a straight compound path with a guide member that reaches past its other members, or an
|
||||
/// object twisted further, is written without the link and warns.
|
||||
#[test]
|
||||
fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
|
||||
// A document with the object `make` builds from its appearance, `ap` with Twist at `angle`,
|
||||
@@ -7619,6 +7753,17 @@ fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
|
||||
n.appearance = ap.into();
|
||||
n
|
||||
};
|
||||
// A compound path of a square and a guide, a rectangle with corners `a` and `b`.
|
||||
let guided = |a: (f64, f64), b: (f64, f64)| {
|
||||
move |doc: &mut Document, ap: Appearance| {
|
||||
let outer = Node::path(doc.alloc_id(), square(), Appearance::default());
|
||||
let path = shapes::rectangle(Rect::new(a.0, a.1, b.0, b.1));
|
||||
let guide = Node::new(doc.alloc_id(), NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: true });
|
||||
let mut n = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(guide)], rule: FillRule::NonZero });
|
||||
n.appearance = ap.into();
|
||||
n
|
||||
}
|
||||
};
|
||||
// A group of a filled square and `second`.
|
||||
let group = |second: fn(&mut Document) -> Node| {
|
||||
move |doc: &mut Document, ap: Appearance| {
|
||||
@@ -7649,6 +7794,8 @@ fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
|
||||
("a square twisted three quarters of a turn back", twisted(-270.0, filled(), path(square())), false),
|
||||
("a straight compound path twisted half a turn", twisted(180.0, filled(), frame), true),
|
||||
("a straight compound path twisted a full turn", twisted(360.0, filled(), frame), false),
|
||||
("a straight compound path with a guide inside it", twisted(45.0, filled(), guided((30.0, 20.0), (70.0, 50.0))), true),
|
||||
("a straight compound path with a guide reaching past it", twisted(45.0, filled(), guided((30.0, 20.0), (120.0, 50.0))), false),
|
||||
("a curve twisted past a quarter turn", twisted(120.0, filled(), path(oval())), false),
|
||||
("a stroked curve", twisted(45.0, Appearance::basic(black_fill(), red.clone(), 2.0), path(oval())), false),
|
||||
("a curved compound path", twisted(-60.0, filled(), ring), true),
|
||||
|
||||
@@ -56,6 +56,7 @@ pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose cli
|
||||
pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths that a plain fill and line can't draw are written as paths of their own inside the clipping (fills under the clipped art, strokes over it) in the editing data for other apps";
|
||||
pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps";
|
||||
pub(super) const LIVE: &str = "blends, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps";
|
||||
pub(super) const GRAPHS: &str = "graphs are written as their art, without their data or settings, in the editing data for other apps";
|
||||
pub(super) const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps";
|
||||
pub(super) const ITEM_TRANSPARENCY: &str =
|
||||
"the opacity and blending modes of single fills and strokes are left out of the editing data for other apps";
|
||||
@@ -205,6 +206,9 @@ pub(super) struct Writer<'a> {
|
||||
mask_level: Option<(u32, u32)>,
|
||||
/// The image just written was written as a placed file.
|
||||
placed: bool,
|
||||
/// The image being written is the copy of a styled object on the layers, on `%_` lines: linked,
|
||||
/// it is written as a placed file there ([`Self::styled_object`]).
|
||||
styled_copy: bool,
|
||||
/// The type just written was written as type, not as its outlines.
|
||||
typed: bool,
|
||||
/// The objects bound to variables so far, in the order they are written: each one's id, its XML
|
||||
@@ -298,6 +302,7 @@ impl<'a> Writer<'a> {
|
||||
hidden: 0,
|
||||
mask_level: None,
|
||||
placed: false,
|
||||
styled_copy: false,
|
||||
typed: false,
|
||||
bound: vec![],
|
||||
texts: texts::TextDoc::default(),
|
||||
@@ -622,6 +627,9 @@ impl<'a> Writer<'a> {
|
||||
if self.in_symbol {
|
||||
self.wrote_fixed |= symbols::redrawn(n);
|
||||
}
|
||||
if n.extras().graph.is_some() && !self.in_look {
|
||||
self.warn(GRAPHS);
|
||||
}
|
||||
// On the layers or in an opacity mask's art, outside art cut to a clipping object: with its
|
||||
// art style, when it has one.
|
||||
if !self.in_look
|
||||
|
||||
@@ -94,7 +94,8 @@ impl Samples {
|
||||
impl Writer<'_> {
|
||||
/// Image object `im` of transparency `xy`: embedded, a raster ([`Self::raster`]); linked, a
|
||||
/// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on
|
||||
/// `%_` lines and in a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is
|
||||
/// `%_` lines (other than a styled object's copy on the layers, [`Self::styled_object`]) and in
|
||||
/// a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is
|
||||
/// cut to a clipping path, the image is in a clipping group of that path.
|
||||
pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) {
|
||||
let doc = self.doc;
|
||||
@@ -125,7 +126,7 @@ impl Writer<'_> {
|
||||
let fit =
|
||||
Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(samples.width), f64::from(im.height.max(1)) / f64::from(samples.height));
|
||||
match &im.link {
|
||||
Some(link) if self.hidden == 0 && !self.in_symbol => {
|
||||
Some(link) if (self.hidden == 0 || self.styled_copy) && !self.in_symbol => {
|
||||
// Cut to a region, the placed file is in a clipping group.
|
||||
self.placed = cut.is_none();
|
||||
self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf);
|
||||
|
||||
@@ -42,7 +42,7 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize {
|
||||
/// The objects of `doc`'s layers and of their opacity masks' art that may be written with an art
|
||||
/// style ([`Writer::styled_object`]), by id (a mask's art root shown, as [`Writer::mask`] writes
|
||||
/// it): paths and compound paths with points and groups, with live effects, linked to a graphic
|
||||
/// style or (paths and compound paths) with a brush on a stroke, and embedded images with one Warp
|
||||
/// style or (paths and compound paths) with a brush on a stroke, and images with one Warp
|
||||
/// ([`warped_image`]), without Opacity & Mask Define
|
||||
/// Knockout Shape (the reader takes an object's transparency
|
||||
/// from its look, which doesn't carry it). A group's members are paths, compound paths or such
|
||||
@@ -100,12 +100,11 @@ fn candidate(n: &Node) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `n` an embedded image without an opacity mask whose appearance is one Warp, with no fills or
|
||||
/// strokes that paint and no effects on them: an image the reader reads with its Warp live
|
||||
/// ([`crate::import::read_art_style`]'s `image_warp`)?
|
||||
/// Is `n` an image, embedded or linked, without an opacity mask whose appearance is one Warp, with
|
||||
/// no fills or strokes that paint and no effects on them: an image the reader reads with its Warp
|
||||
/// live ([`crate::import::read_art_style`]'s `image_warp`)?
|
||||
fn warped_image(n: &Node) -> bool {
|
||||
let NodeKind::Image(im) = &n.kind else { return false };
|
||||
im.link.is_none()
|
||||
matches!(n.kind, NodeKind::Image(_))
|
||||
&& n.extras().mask.is_none()
|
||||
&& matches!(n.appearance.effects.as_slice(), [e] if e.id.starts_with("warp."))
|
||||
&& n.appearance.items.iter().all(|i| i.paint().is_none() && i.effects().is_empty())
|
||||
@@ -725,7 +724,8 @@ impl Writer<'_> {
|
||||
/// glows or feathers, the object's transparency
|
||||
/// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or
|
||||
/// compound path's points painted with nothing, or with its gradient fill when its style has
|
||||
/// one; a group with its members; an image with its pixels), its name and opacity mask; then
|
||||
/// one; a group with its members; an image with its pixels, or on the layers a linked image as
|
||||
/// a placed file with its path, folder and preview), its name and opacity mask; then
|
||||
/// `1 (name) XW`.
|
||||
/// The look's group and the object itself are counted first ([`Self::take_objects`]): without
|
||||
/// room for them, nothing is written, and the object counts neither as written with its graphic
|
||||
@@ -772,6 +772,9 @@ impl Writer<'_> {
|
||||
// The object, on `%_` lines, in its own state (the look's members may have changed it).
|
||||
self.state(orig.locked, !orig.visible);
|
||||
let saved = std::mem::take(&mut self.body);
|
||||
// On the layers, a linked image's copy is a placed file; in an opacity mask's art, it is
|
||||
// embedded, as other linked images there are.
|
||||
let on_layers = self.hidden == 0;
|
||||
self.hidden += 1;
|
||||
let fit = drawn_outline(n, orig).map(|p| p.bounding_box());
|
||||
// A path's or an image's objects were kept for it above; it counts them itself.
|
||||
@@ -781,7 +784,11 @@ impl Writer<'_> {
|
||||
match &orig.kind {
|
||||
NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy),
|
||||
NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy),
|
||||
NodeKind::Image(im) => self.image(&self.doc.shown_image(orig, im), xy),
|
||||
NodeKind::Image(im) => {
|
||||
let was = std::mem::replace(&mut self.styled_copy, on_layers);
|
||||
self.image(&self.doc.shown_image(orig, im), xy);
|
||||
self.styled_copy = was;
|
||||
}
|
||||
NodeKind::Group { children, .. } => {
|
||||
self.line("u");
|
||||
for c in children {
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//! next layers: a clipping group in each layer, read by [`masks`].
|
||||
//! - Ruler guides: a straight `(N) *` guide path, directly on a layer, that runs across the whole
|
||||
//! canvas (16,383 points around the template box's centre) or from edge to edge of one artboard
|
||||
//! is a ruler guide on that layer ([`Document::guides`]); other guide paths stay paths.
|
||||
//! is a ruler guide on that layer ([`Document::guides`]), shown or hidden, with the path's name,
|
||||
//! lock and visibility; other guide paths stay paths.
|
||||
//! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted
|
||||
//! with `W` clips); a group's transparency and name follow its end. A wraparound group `*w` …
|
||||
//! `*W` (version 7 specification §15.4.2) is a group whose objects other than its type wrap
|
||||
@@ -78,7 +79,8 @@
|
||||
//!
|
||||
//! - Graphs (version 7 specification §17): `%_Gs`, the graph's data and settings on `%_` lines,
|
||||
//! its drawn art as ordinary groups, paths and type (each object followed by `%_… Go`), `%_GS`.
|
||||
//! The drawn art is read; the data and settings are left out, with a note.
|
||||
//! The drawn art is read as it is. Its group keeps the data and settings when they read whole
|
||||
//! ([`graph`]); otherwise they are left out, with a note.
|
||||
//!
|
||||
//! - Swatches: the palette (`%AI5_BeginPalette`), read by [`super::palette`], replaces the
|
||||
//! default swatches.
|
||||
@@ -133,6 +135,7 @@ mod art;
|
||||
mod containers;
|
||||
mod directives;
|
||||
mod document;
|
||||
mod graph;
|
||||
mod masks;
|
||||
mod on_path;
|
||||
mod ops;
|
||||
@@ -146,12 +149,13 @@ pub use document::{
|
||||
symbols, variables,
|
||||
};
|
||||
pub(crate) use document::{slot_frame, slot_of};
|
||||
use graph::Graph;
|
||||
pub(crate) use ops::{read_art_style, read_style_list, style_moves_with};
|
||||
#[cfg(test)]
|
||||
pub(crate) use raster::with_image_limits;
|
||||
pub(crate) use types::MAX_NODES;
|
||||
pub use types::PlacedPage;
|
||||
use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead};
|
||||
use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead};
|
||||
#[cfg(test)]
|
||||
pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, LINKED_PLACEHOLDERS, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED};
|
||||
pub(super) use types::{blend_mode, blend_name};
|
||||
@@ -670,7 +674,7 @@ struct Reader<'a> {
|
||||
brushes_kept: BrushesKept,
|
||||
/// Brush objects that weren't read.
|
||||
brushes_unread: usize,
|
||||
/// The graph being read (see [`Reader::graph_record`]).
|
||||
/// The graph being read (see [`graph`]).
|
||||
graph: Option<Graph>,
|
||||
/// The multi-layer mask being read (`Mb` … `MB`), which goes on past a layer's end.
|
||||
multi_mask: Option<masks::MultiMask>,
|
||||
|
||||
@@ -234,11 +234,10 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
let kind = NodeKind::Path { path, rule: gs.rule, live: None, clipping: clip, guide };
|
||||
let mut node = self.new_node(kind, &gs);
|
||||
// A ruler guide is written as a straight `(N) *` guide path on its layer.
|
||||
// A ruler guide is written as a straight `(N) *` guide path on its layer, shown or hidden.
|
||||
if guide
|
||||
&& op == "N"
|
||||
&& !hidden
|
||||
&& !gs.hidden
|
||||
&& !clip
|
||||
&& self.pattern.is_none()
|
||||
&& matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. }))
|
||||
|
||||
@@ -1165,7 +1165,9 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
|
||||
pub(super) fn finish(mut self) -> Result<Structure, String> {
|
||||
if self.graph.take().is_some() {
|
||||
if self.graph.is_some() {
|
||||
self.graph_plain();
|
||||
self.graph = None;
|
||||
self.warn(super::types::GRAPH_UNENDED);
|
||||
}
|
||||
// Envelopes whose plugin never closed them keep their drawn art.
|
||||
@@ -1443,7 +1445,8 @@ fn ruler_guide(a: Point, b: Point, centre: Option<(f64, f64)>, boards: &[Rect])
|
||||
}
|
||||
|
||||
/// Makes the children of the layers and sublayers of `nodes` that `pick` makes a guide of into
|
||||
/// ruler guide nodes with the same ids, moved to the top of their layer in their order.
|
||||
/// ruler guide nodes with the same ids, names, locks and visibility, moved to the top of their layer
|
||||
/// in their order.
|
||||
fn take_ruler_guides(nodes: &mut [Arc<Node>], pick: &dyn Fn(NodeId) -> Option<Guide>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
@@ -1456,7 +1459,11 @@ fn take_ruler_guides(nodes: &mut [Arc<Node>], pick: &dyn Fn(NodeId) -> Option<Gu
|
||||
let mut guides = vec![];
|
||||
kids.retain(|k| match pick(k.id) {
|
||||
Some(g) => {
|
||||
guides.push(Arc::new(Node::new(k.id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard })));
|
||||
let mut n = Node::new(k.id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard });
|
||||
n.name = k.name.clone();
|
||||
n.locked = k.locked;
|
||||
n.visible = k.visible;
|
||||
guides.push(Arc::new(n));
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
//! A graph's records (version 7 specification §17 and appendix A) and the live graph they give.
|
||||
//!
|
||||
//! A graph is `%_Gs`, its functional records on `%_` lines (`Gb`, `Gy`, `Gd`, any `Gj`, three
|
||||
//! `Ga` … `GA` axes, any `Gw`, `Gz`, `Gc` cells and `GC`), any `version Gt` … `GT` customizations, its
|
||||
//! drawn art in one group (objects followed by `%_… Go`), then `%_GS`. The drawn art is always
|
||||
//! read as it is. The group also keeps the graph's data and settings ([`GraphSpec`]) when every
|
||||
//! record has the shape and values appendix A gives and the model holds each setting, the table
|
||||
//! has data, the art draws the plot's left and bottom edges, and each series' group is tagged, is
|
||||
//! one of the graph's own groups and paints its paths with one fill and one stroke, which the
|
||||
//! graph keeps as the series' paint.
|
||||
//! Any other graph stays plain art, with a note.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{PathSeg, Point};
|
||||
use vectorcraft_doc::{Appearance, GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint, TickLength};
|
||||
|
||||
use super::types::{GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, Kind};
|
||||
use super::{Affine, AppearanceItem, BlendMode, Node, NodeId, NodeKind, Paint, Reader, Rect, V, obj};
|
||||
|
||||
/// Most cells of a graph's table, its label row and column included.
|
||||
const MAX_CELLS: usize = 2_600_000;
|
||||
|
||||
/// Longest cell string (appendix A, `Gc`).
|
||||
const MAX_CELL_TEXT: usize = 255;
|
||||
|
||||
/// Most characters of an axis label's text before or after its numbers (appendix A, `Ga`, `GA`).
|
||||
const MAX_AFFIX: usize = 9;
|
||||
|
||||
/// Most tick marks per division a graph draws.
|
||||
const MAX_TICK_MARKS: usize = 20;
|
||||
|
||||
/// Most divisions of a value axis with set values.
|
||||
const MAX_DIVISIONS: f64 = 100.0;
|
||||
|
||||
/// How far an axis line may be from the plot's edge, in points.
|
||||
const EDGE: f64 = 0.01;
|
||||
|
||||
/// The axes in the order the records give them: bottom, left, right.
|
||||
const AXES: [i64; 3] = [1, 2, 4];
|
||||
|
||||
/// The `Go` targets appendix A lists.
|
||||
const TARGETS: [i64; 11] = [1, 2, 4, 5, 6, 8, 9, 10, 15, 20, 22];
|
||||
|
||||
/// The version of a graph's customizations (appendix A, `Gt`).
|
||||
const CUSTOM_VERSION: i64 = 2;
|
||||
|
||||
/// `Go`'s target for one series, including its legend.
|
||||
const SERIES: i64 = 2;
|
||||
|
||||
/// A graph being read (`%_Gs` … `%_GS`).
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Graph {
|
||||
/// Between `Gt` and `GT`, where the graph's settings may use paint and type operators on `%_`
|
||||
/// lines too.
|
||||
pub(super) settings: bool,
|
||||
/// How many frames were open at its `Gs`: a layer that ends with fewer ends the graph.
|
||||
pub(super) frames: usize,
|
||||
/// How many objects the innermost frame held at `Gs`: the drawn art is the one after them.
|
||||
siblings: usize,
|
||||
/// The records read so far, while they can still give a live graph.
|
||||
live: Option<Box<Records>>,
|
||||
}
|
||||
|
||||
/// Where the records are, in the order appendix A gives.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Bounds,
|
||||
Style,
|
||||
Dialog,
|
||||
/// Before the `Ga` of axis `n`.
|
||||
Axis(usize),
|
||||
/// Before the `GA` of axis `n`.
|
||||
AxisEnd(usize),
|
||||
Widths,
|
||||
Cells,
|
||||
/// After `GC`: customizations, then the drawn art.
|
||||
Done,
|
||||
/// Between `Gt` and `GT`.
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// One axis: `whichAxis beforeString Ga` and `useManualValues tickMarkType minimumValue maximumValue
|
||||
/// betweenValue smallTicksPerValue drawMarksBetweenLabels afterString GA`.
|
||||
#[derive(Debug, Default)]
|
||||
struct Axis {
|
||||
before: Vec<u8>,
|
||||
manual: bool,
|
||||
tick: TickLength,
|
||||
min: f64,
|
||||
max: f64,
|
||||
between: f64,
|
||||
small: usize,
|
||||
between_labels: bool,
|
||||
after: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A cell of the table (`Gc`).
|
||||
#[derive(Debug)]
|
||||
enum Cell {
|
||||
Num(f64),
|
||||
Text(Vec<u8>),
|
||||
}
|
||||
|
||||
/// The records of a graph read so far.
|
||||
#[derive(Debug, Default)]
|
||||
struct Records {
|
||||
stage: Stage,
|
||||
spec: GraphSpec,
|
||||
axes: [Axis; 3],
|
||||
/// `Gw`: the first column and how many widths.
|
||||
widths: Vec<(usize, usize)>,
|
||||
/// `Gz`: rows, columns, first data row and first data column.
|
||||
size: Option<[usize; 4]>,
|
||||
cells: Vec<Cell>,
|
||||
/// The groups `Go` tags as one series, and the column each names.
|
||||
series: Vec<(NodeId, usize)>,
|
||||
}
|
||||
|
||||
/// A whole number.
|
||||
fn int(v: &V) -> Option<i64> {
|
||||
v.num().filter(|x| x.is_finite() && x.fract() == 0.0 && x.abs() < 1e15).map(|x| x as i64)
|
||||
}
|
||||
|
||||
/// 0 or 1.
|
||||
fn flag(v: &V) -> Option<bool> {
|
||||
match int(v)? {
|
||||
0 => Some(false),
|
||||
1 => Some(true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn finite(v: &V) -> Option<f64> {
|
||||
v.num().filter(|x| x.is_finite())
|
||||
}
|
||||
|
||||
/// A count from 0 to `max`.
|
||||
fn count(v: &V, max: usize) -> Option<usize> {
|
||||
usize::try_from(int(v)?).ok().filter(|n| *n <= max)
|
||||
}
|
||||
|
||||
/// `tickMarkType`: 13 none, 14 short, 15 the length of the plot.
|
||||
fn tick(v: &V) -> Option<TickLength> {
|
||||
match int(v)? {
|
||||
13 => Some(TickLength::None),
|
||||
14 => Some(TickLength::Short),
|
||||
15 => Some(TickLength::Full),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `graphType`: 5 grouped column, 6 stacked column, 7 line, 10 area. Pie (8) and scatter (9) graphs
|
||||
/// keep settings the records don't give the model, and other values are not graph types.
|
||||
fn kind(v: &V) -> Option<GraphKind> {
|
||||
match int(v)? {
|
||||
5 => Some(GraphKind::Column),
|
||||
6 => Some(GraphKind::StackedColumn),
|
||||
7 => Some(GraphKind::Line),
|
||||
10 => Some(GraphKind::Area),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A percentage of the available width, 1 to 1000.
|
||||
fn percent(v: &V) -> Option<f64> {
|
||||
finite(v).filter(|p| (1.0..=1000.0).contains(p))
|
||||
}
|
||||
|
||||
/// The tick count and values of an axis: with set values (`useManualValues` 1), the divisions
|
||||
/// between its lowest and highest value, a whole number of `betweenValue` steps from the lowest up.
|
||||
fn values(a: &Axis) -> Option<(usize, Option<f64>, Option<f64>)> {
|
||||
if !a.manual {
|
||||
return Some((0, None, None));
|
||||
}
|
||||
let n = (a.max - a.min) / a.between;
|
||||
let whole = n.round();
|
||||
let exact = (n - whole).abs() <= 1e-6 * whole.max(1.0);
|
||||
(a.between > 0.0 && a.max > a.min && exact && (1.0..=MAX_DIVISIONS).contains(&whole)).then_some((whole as usize, Some(a.min), Some(a.max)))
|
||||
}
|
||||
|
||||
impl Records {
|
||||
/// Take the record `w` with its operands `vals`; `None` when its shape, values or place aren't
|
||||
/// those appendix A gives, or the model doesn't hold a value it sets. `to_doc` takes art space
|
||||
/// to the document.
|
||||
fn step(&mut self, w: &str, vals: &[V], to_doc: Affine) -> Option<()> {
|
||||
match (w, self.stage) {
|
||||
("Gb", Stage::Bounds) => {
|
||||
let [l, t, r, b] = vals else { return None };
|
||||
let [l, t, r, b] = [finite(l)?, finite(t)?, finite(r)?, finite(b)?];
|
||||
let plot = to_doc.transform_rect_bbox(Rect::new(l, b, r, t));
|
||||
let sound = r > l && t > b && plot.width() > 0.0 && plot.height() > 0.0 && plot.width().is_finite() && plot.height().is_finite();
|
||||
sound.then_some(())?;
|
||||
self.spec.rect = plot;
|
||||
self.stage = Stage::Style;
|
||||
}
|
||||
("Gy", Stage::Style) => {
|
||||
self.style(vals)?;
|
||||
self.stage = Stage::Dialog;
|
||||
}
|
||||
("Gd", Stage::Dialog) => {
|
||||
let [col_width, precision, series_order, both_axes, bar, group, edge] = vals else { return None };
|
||||
count(col_width, 20).filter(|w| *w >= 3)?;
|
||||
count(precision, 10)?;
|
||||
// `useBothAxis` copies one axis' values to the other, which the model doesn't do.
|
||||
(!flag(both_axes)?).then_some(())?;
|
||||
self.spec.first_column_in_front = flag(series_order)?;
|
||||
self.spec.column_width = percent(bar)?;
|
||||
self.spec.cluster_width = percent(group)?;
|
||||
self.spec.edge_to_edge = flag(edge)?;
|
||||
self.stage = Stage::Axis(0);
|
||||
}
|
||||
("Ga", Stage::Axis(n)) if n < AXES.len() => {
|
||||
let [axis, V::Str(before)] = vals else { return None };
|
||||
(int(axis)? == *AXES.get(n)?).then_some(())?;
|
||||
self.axes.get_mut(n)?.before = before.clone();
|
||||
self.stage = Stage::AxisEnd(n);
|
||||
}
|
||||
("GA", Stage::AxisEnd(n)) => {
|
||||
let [manual, ticks, min, max, between, small, between_labels, V::Str(after)] = vals else { return None };
|
||||
let a = self.axes.get_mut(n)?;
|
||||
(a.manual, a.tick) = (flag(manual)?, tick(ticks)?);
|
||||
(a.min, a.max, a.between) = (finite(min)?, finite(max)?, finite(between)?);
|
||||
a.small = count(small, 1000)?;
|
||||
a.between_labels = flag(between_labels)?;
|
||||
a.after = after.clone();
|
||||
self.stage = Stage::Axis(n + 1);
|
||||
}
|
||||
("Gw", Stage::Axis(3) | Stage::Widths) => {
|
||||
// `col width1 … widthX numParams Gw`: numParams counts `col` and the 1 to 14 widths.
|
||||
let (params, rest) = vals.split_last()?;
|
||||
let params = count(params, 15).filter(|p| *p >= 2 && *p == rest.len())?;
|
||||
let (col, widths) = rest.split_first()?;
|
||||
let col = count(col, MAX_CELLS)?;
|
||||
widths.iter().all(|w| int(w).is_some_and(|w| w == -1 || (3..=20).contains(&w))).then_some(())?;
|
||||
self.widths.push((col, params - 1));
|
||||
self.stage = Stage::Widths;
|
||||
}
|
||||
("Gz", Stage::Axis(3) | Stage::Widths) => {
|
||||
let [rows, cols, first_row, first_col] = vals else { return None };
|
||||
let (rows, cols) = (count(rows, MAX_CELLS)?, count(cols, MAX_CELLS)?);
|
||||
let (first_row, first_col) = (usize::from(flag(first_row)?), usize::from(flag(first_col)?));
|
||||
rows.checked_sub(first_row).filter(|c| (1..=MAX_GRAPH_CATEGORIES).contains(c))?;
|
||||
cols.checked_sub(first_col).filter(|s| (1..=MAX_GRAPH_SERIES).contains(s))?;
|
||||
rows.checked_mul(cols).filter(|n| *n <= MAX_CELLS)?;
|
||||
self.widths.iter().all(|(col, n)| col.checked_add(*n).is_some_and(|end| end <= cols)).then_some(())?;
|
||||
self.size = Some([rows, cols, first_row, first_col]);
|
||||
self.stage = Stage::Cells;
|
||||
}
|
||||
("Gc", Stage::Cells) => {
|
||||
// One to fifteen values, at most one of them a string.
|
||||
let strings = vals.iter().filter(|v| matches!(v, V::Str(_))).count();
|
||||
((1..=15).contains(&vals.len()) && strings <= 1).then_some(())?;
|
||||
let [rows, cols, ..] = self.size?;
|
||||
(self.cells.len() + vals.len() <= rows * cols).then_some(())?;
|
||||
for v in vals {
|
||||
self.cells.push(match v {
|
||||
V::Str(s) if s.len() <= MAX_CELL_TEXT => Cell::Text(s.clone()),
|
||||
_ => Cell::Num(finite(v)?),
|
||||
});
|
||||
}
|
||||
}
|
||||
("GC", Stage::Cells) if vals.is_empty() => self.stage = Stage::Done,
|
||||
// `version Gt`, of version 2. Customizations are edits made after the graph was built, which
|
||||
// the model can't replay: only an empty list keeps the graph live.
|
||||
("Gt", Stage::Done) => {
|
||||
let [version] = vals else { return None };
|
||||
(int(version)? == CUSTOM_VERSION).then_some(())?;
|
||||
self.stage = Stage::Custom;
|
||||
}
|
||||
("GT", Stage::Custom) if vals.is_empty() => self.stage = Stage::Done,
|
||||
_ => return None,
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// `graphType shadow dataPointOrder pieLegendStyle drawMarks drawLines drawLinesAsShapes
|
||||
/// drawLegendsAcrossTop lineShapeWidth whichAxis [piePercentage piePctDigits] Gy`.
|
||||
fn style(&mut self, vals: &[V]) -> Option<()> {
|
||||
let (base, pie) = match vals.len() {
|
||||
10 => (vals, None),
|
||||
12 => vals.split_at_checked(10).map(|(b, p)| (b, Some(p)))?,
|
||||
_ => return None,
|
||||
};
|
||||
let [ty, shadow, order, legend, marks, lines, shapes, across, width, axis] = base else { return None };
|
||||
self.spec.kind = kind(ty)?;
|
||||
self.spec.drop_shadow = flag(shadow)?;
|
||||
self.spec.first_row_in_front = flag(order)?;
|
||||
// 14: legends as in bar and line graphs; 15 (in wedges) and 16 (none) are pie styles.
|
||||
(int(legend)? == 14).then_some(())?;
|
||||
self.spec.legend = true;
|
||||
self.spec.mark_points = flag(marks)?;
|
||||
self.spec.connect_points = flag(lines)?;
|
||||
// Lines drawn as shapes have a width the model doesn't keep.
|
||||
(!flag(shapes)?).then_some(())?;
|
||||
self.spec.filled_lines = false;
|
||||
self.spec.legend_across_top = flag(across)?;
|
||||
finite(width).filter(|w| (0.0..=100.0).contains(w))?;
|
||||
// 44: the series are measured against the left axis; 45 (right) isn't kept per series.
|
||||
(int(axis)? == 44).then_some(())?;
|
||||
if let Some([percentage, digits]) = pie {
|
||||
// Pie percentages, which only pie graphs show; shown (1) isn't kept.
|
||||
(!flag(percentage)?).then_some(())?;
|
||||
int(digits).filter(|d| *d >= 0)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The graph's data and settings, its strings read as names in `names`; `None` when the table
|
||||
/// or an axis holds what the model can't.
|
||||
fn finish(&self, names: &mut obj::Names) -> Option<GraphSpec> {
|
||||
let [rows, cols, first_row, first_col] = self.size?;
|
||||
let mut spec = self.spec.clone();
|
||||
let text = |names: &mut obj::Names, b: &[u8], max: usize| {
|
||||
let t = names.quiet(b);
|
||||
(t.chars().count() <= max && !t.chars().any(char::is_control)).then(|| names.bytes(b))
|
||||
};
|
||||
// Axes: the bottom one is the category axis, the left one the value axis; the right one keeps
|
||||
// its settings for when it is shown.
|
||||
let [bottom, left, right] = &self.axes;
|
||||
(bottom.before.is_empty() && bottom.after.is_empty() && !bottom.manual).then_some(())?;
|
||||
(!left.between_labels && !right.between_labels).then_some(())?;
|
||||
(spec.category_tick_length, spec.category_tick_marks) = (bottom.tick, bottom.small.max(1));
|
||||
spec.ticks_between_labels = bottom.between_labels;
|
||||
(spec.tick_length, spec.tick_marks) = (left.tick, left.small.max(1));
|
||||
(spec.right_tick_length, spec.right_tick_marks) = (right.tick, right.small.max(1));
|
||||
(spec.category_tick_marks <= MAX_TICK_MARKS && spec.tick_marks <= MAX_TICK_MARKS && spec.right_tick_marks <= MAX_TICK_MARKS).then_some(())?;
|
||||
(spec.ticks, spec.axis_min, spec.axis_max) = values(left)?;
|
||||
(spec.right_ticks, spec.right_axis_min, spec.right_axis_max) = values(right)?;
|
||||
spec.prefix = text(names, &left.before, MAX_AFFIX)?;
|
||||
spec.suffix = text(names, &left.after, MAX_AFFIX)?;
|
||||
spec.right_prefix = text(names, &right.before, MAX_AFFIX)?;
|
||||
spec.right_suffix = text(names, &right.after, MAX_AFFIX)?;
|
||||
// The table, row by row; cells left out at its end are empty.
|
||||
let cell = |r: usize, c: usize| self.cells.get(r * cols + c);
|
||||
let label = |names: &mut obj::Names, r: usize, c: usize| match cell(r, c) {
|
||||
None => Some(String::new()),
|
||||
Some(Cell::Text(t)) => text(names, t, MAX_CELL_TEXT),
|
||||
Some(Cell::Num(_)) => None,
|
||||
};
|
||||
// The corner of a table with both label row and label column holds nothing.
|
||||
let corner_empty = match cell(0, 0) {
|
||||
None => true,
|
||||
Some(Cell::Text(t)) => t.is_empty(),
|
||||
Some(Cell::Num(_)) => false,
|
||||
};
|
||||
if first_row == 1 && first_col == 1 && !corner_empty {
|
||||
return None;
|
||||
}
|
||||
spec.series = if first_row == 1 { (first_col..cols).map(|c| label(names, 0, c)).collect::<Option<_>>()? } else { vec![] };
|
||||
spec.categories = if first_col == 1 { (first_row..rows).map(|r| label(names, r, 0)).collect::<Option<_>>()? } else { vec![] };
|
||||
let mut data = Vec::with_capacity(rows - first_row);
|
||||
for r in first_row..rows {
|
||||
let row = (first_col..cols)
|
||||
.map(|c| match cell(r, c) {
|
||||
None => Some(None),
|
||||
Some(Cell::Text(t)) if t.is_empty() => Some(None),
|
||||
Some(Cell::Text(_)) => None,
|
||||
Some(Cell::Num(v)) => Some(Some(*v)),
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
data.push(row);
|
||||
}
|
||||
data.iter().flatten().any(Option::is_some).then_some(())?;
|
||||
spec.set_cells(data);
|
||||
Some(spec)
|
||||
}
|
||||
|
||||
/// The series index of each group `Go` tagged as one series, by id: every series once, its
|
||||
/// columns counted in the table or among the series alike. `None` when the tags are missing,
|
||||
/// repeated or out of range: the series' paint is read from its group.
|
||||
fn series_tags(&self) -> Option<HashMap<NodeId, u32>> {
|
||||
let [_, cols, _, first_col] = self.size?;
|
||||
let n = cols - first_col;
|
||||
let ids: HashSet<NodeId> = self.series.iter().map(|(id, _)| *id).collect();
|
||||
(ids.len() == self.series.len() && self.series.len() == n).then_some(())?;
|
||||
let indexes = |offset: usize| -> Option<HashMap<NodeId, u32>> {
|
||||
let mut seen = HashSet::new();
|
||||
let tags: HashMap<NodeId, u32> = self
|
||||
.series
|
||||
.iter()
|
||||
.map(|(id, col)| {
|
||||
let s = col.checked_sub(offset).filter(|s| *s < n && seen.insert(*s))?;
|
||||
Some((*id, u32::try_from(s).ok()?))
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
Some(tags)
|
||||
};
|
||||
// With a label column, the columns read as the table's or as the series' are told apart by the
|
||||
// set they make; without one, they are the same.
|
||||
match (indexes(0), indexes(first_col)) {
|
||||
(Some(a), Some(b)) if a == b => Some(a),
|
||||
(Some(a), None) | (None, Some(a)) => Some(a),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `art` draws the plot `plot`'s left edge and its bottom edge (the document's y runs down)
|
||||
/// as straight stroked lines: the value axis and the category axis, drawn on the plot's edges.
|
||||
fn draws_axes(art: &Node, plot: Rect) -> bool {
|
||||
let near = |a: Point, b: Point| (a - b).hypot() <= EDGE;
|
||||
let on = |s: &PathSeg, a: Point, b: Point| match s {
|
||||
PathSeg::Line(l) => (near(l.p0, a) && near(l.p1, b)) || (near(l.p0, b) && near(l.p1, a)),
|
||||
_ => false,
|
||||
};
|
||||
let (top_left, bottom_left, bottom_right) = (Point::new(plot.x0, plot.y0), Point::new(plot.x0, plot.y1), Point::new(plot.x1, plot.y1));
|
||||
let (mut left, mut bottom) = (false, false);
|
||||
let mut stack = vec![art];
|
||||
while let Some(n) = stack.pop() {
|
||||
// Only shown objects draw the axes, and a path only with a stroke.
|
||||
if !n.visible || n.opacity <= 0.0 || (left && bottom) {
|
||||
continue;
|
||||
}
|
||||
let stroked = n.appearance.items.iter().any(|it| !it.is_fill() && it.visible() && it.opacity() > 0.0 && !it.paint().is_none());
|
||||
if let NodeKind::Path { path, .. } = &n.kind
|
||||
&& stroked
|
||||
{
|
||||
for s in path.to_bezpath().segments() {
|
||||
left |= on(&s, top_left, bottom_left);
|
||||
bottom |= on(&s, bottom_left, bottom_right);
|
||||
}
|
||||
}
|
||||
stack.extend(n.children().into_iter().flatten().map(|c| &**c));
|
||||
}
|
||||
left && bottom
|
||||
}
|
||||
|
||||
/// Give the groups among `n`'s children that `tags` holds their series index.
|
||||
fn tag_series(n: &mut Node, tags: &HashMap<NodeId, u32>) {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
if let Some(i) = tags.get(&c.id) {
|
||||
Arc::make_mut(c).extras_mut().series_index = Some(*i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every node `tags` holds is a group among `n`'s children: the graph draws its series
|
||||
/// groups again as its own children.
|
||||
fn tags_groups(n: &Node, tags: &HashMap<NodeId, u32>) -> bool {
|
||||
let found = n.children().into_iter().flatten().filter(|c| tags.contains_key(&c.id) && matches!(c.kind, NodeKind::Group { .. })).count();
|
||||
found == tags.len()
|
||||
}
|
||||
|
||||
/// The paint of the shown fill (`fill`) or stroke of `p`, with a stroke's weight; `None` when it
|
||||
/// has none. `Err` when it has more than one, or that fill or stroke has an opacity, a blend mode
|
||||
/// or effects of its own, which a series' paint doesn't hold.
|
||||
fn shown(p: &Node, fill: bool) -> Result<Option<(Paint, f64)>, ()> {
|
||||
let mut items = p.appearance.items.iter().filter(|it| it.is_fill() == fill && it.visible());
|
||||
let Some(item) = items.next() else { return Ok(None) };
|
||||
if items.next().is_some() || item.opacity() != 1.0 || item.blend() != BlendMode::Normal || !item.effects().is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
if item.paint().is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let width = match item {
|
||||
AppearanceItem::Stroke(s) => s.width,
|
||||
AppearanceItem::Fill(_) => 0.0,
|
||||
};
|
||||
Ok(Some((item.paint().clone(), width)))
|
||||
}
|
||||
|
||||
/// Whether `n` is shown, fully opaque, in Normal mode, not isolated and without an opacity mask:
|
||||
/// the graph draws its series groups and their marks so.
|
||||
fn as_drawn(n: &Node) -> bool {
|
||||
n.visible && n.opacity == 1.0 && n.blend == BlendMode::Normal && !n.isolate && n.extras().mask.is_none()
|
||||
}
|
||||
|
||||
/// Each series' fill and stroke, read from its group (`tags`), which holds paths only, one at
|
||||
/// least: the paths with a fill share it, and every path has the same stroke or none. Only a line
|
||||
/// graph (`line`) has paths without a fill: its lines. `None` when they differ, or the group, its
|
||||
/// own appearance or a path is drawn otherwise than [`as_drawn`].
|
||||
fn series_paints(art: &Node, tags: &HashMap<NodeId, u32>, line: bool) -> Option<Vec<SeriesPaint>> {
|
||||
let mut paints = vec![SeriesPaint::default(); tags.len()];
|
||||
for group in art.children().into_iter().flatten() {
|
||||
let Some(slot) = tags.get(&group.id).and_then(|i| paints.get_mut(usize::try_from(*i).ok()?)) else { continue };
|
||||
(as_drawn(group) && *group.appearance == Appearance::default()).then_some(())?;
|
||||
let paths: Vec<&Arc<Node>> = group.children().into_iter().flatten().collect();
|
||||
(!paths.is_empty() && paths.iter().all(|p| matches!(p.kind, NodeKind::Path { .. }))).then_some(())?;
|
||||
let (mut fills, mut strokes) = (vec![], vec![]);
|
||||
for p in &paths {
|
||||
as_drawn(p).then_some(())?;
|
||||
fills.extend(shown(p, true).ok()?);
|
||||
strokes.push(shown(p, false).ok()?);
|
||||
}
|
||||
let fill = fills.first().map(|(paint, _)| paint.clone());
|
||||
fills.iter().all(|(paint, _)| Some(paint) == fill.as_ref()).then_some(())?;
|
||||
(line || fills.len() == paths.len()).then_some(())?;
|
||||
let stroke = strokes.first().cloned().flatten();
|
||||
strokes.iter().all(|s| *s == stroke).then_some(())?;
|
||||
slot.fill = fill;
|
||||
(slot.stroke, slot.stroke_width) = match stroke {
|
||||
Some((paint, width)) => (Some(paint), Some(width)),
|
||||
None => (Some(Paint::None), None),
|
||||
};
|
||||
}
|
||||
Some(paints)
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
/// A graph's records: `%_Gs` starts the graph on a layer, `GS` ends it, and its data and settings
|
||||
/// (the operators in [`GRAPH_OPS`], and any operator on a `%_` line between `Gt` and `GT`) are
|
||||
/// taken with their operands `vals`. Its drawn art, and anything else, is read as usual. `true`
|
||||
/// when `w` was one of them. `ended`: whether a group and a mesh had just ended before `w`, which
|
||||
/// a `Go` tag after them leaves so.
|
||||
pub(super) fn graph_record(&mut self, w: &str, vals: &[V], hidden: bool, ended: (bool, bool)) -> bool {
|
||||
let depth = self.frames.len();
|
||||
let Some(g) = self.graph.as_mut() else {
|
||||
// Outside a graph, its operators stay ones the reader doesn't know.
|
||||
if w == "Gs" && hidden && !self.frames.is_empty() {
|
||||
let siblings = self.frames.last().map_or(0, |f| f.children.len());
|
||||
// A graph is live only where its art is in the document's space, in a layer or a group.
|
||||
let placed = self.pattern.is_none()
|
||||
&& self.symbol.is_none()
|
||||
&& vals.is_empty()
|
||||
&& matches!(self.frames.last().map(|f| &f.kind), Some(Kind::Layer(_) | Kind::Group | Kind::Clip));
|
||||
self.graph = Some(Graph { settings: false, frames: depth, siblings, live: placed.then(Box::default) });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// The frame the graph started in closed: its art isn't where it was.
|
||||
if depth < g.frames {
|
||||
self.graph_plain();
|
||||
}
|
||||
let Some(g) = self.graph.as_mut() else { return false };
|
||||
let stage = g.live.as_ref().map(|r| r.stage);
|
||||
match w {
|
||||
// A graph doesn't start inside another: the one being read goes on.
|
||||
"Gs" => {
|
||||
self.graph_plain();
|
||||
self.warn(GRAPH_UNENDED);
|
||||
}
|
||||
"GS" => self.graph_end(vals.is_empty() && hidden),
|
||||
"Gt" | "GT" => {
|
||||
g.settings = w == "Gt";
|
||||
self.graph_step(w, vals, hidden);
|
||||
}
|
||||
"Go" => {
|
||||
self.graph_tag(vals, hidden);
|
||||
(self.group_ended, self.mesh_ended) = ended;
|
||||
}
|
||||
_ if GRAPH_OPS.contains(&w) => self.graph_step(w, vals, hidden),
|
||||
_ if hidden && g.settings => self.graph_plain(),
|
||||
_ => {
|
||||
// Before its table ends, a graph has only its records.
|
||||
if !matches!(stage, None | Some(Stage::Done)) {
|
||||
self.graph_plain();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The graph being read can't be live: its art stays plain, with a note.
|
||||
pub(super) fn graph_plain(&mut self) {
|
||||
if let Some(g) = self.graph.as_mut() {
|
||||
g.live = None;
|
||||
}
|
||||
self.warn(GRAPH_PLAIN);
|
||||
}
|
||||
|
||||
/// Take a functional record or customization on a `%_` line.
|
||||
fn graph_step(&mut self, w: &str, vals: &[V], hidden: bool) {
|
||||
if self.graph.as_ref().is_none_or(|g| g.live.is_none()) {
|
||||
return;
|
||||
}
|
||||
let to_doc = if w == "Gb" { self.space() } else { Affine::IDENTITY };
|
||||
let ok = hidden && self.graph.as_mut().and_then(|g| g.live.as_mut()).and_then(|r| r.step(w, vals, to_doc)).is_some();
|
||||
if !ok {
|
||||
self.graph_plain();
|
||||
}
|
||||
}
|
||||
|
||||
/// `target column row whichAxis Go` after an object of the drawn art: a group tagged as one
|
||||
/// series is kept with the column it names.
|
||||
fn graph_tag(&mut self, vals: &[V], hidden: bool) {
|
||||
let depth = self.frames.len();
|
||||
let last = self
|
||||
.after_object
|
||||
.then(|| self.frames.last().and_then(|f| f.children.last()))
|
||||
.flatten()
|
||||
.map(|n| (n.id, matches!(n.kind, NodeKind::Group { .. })));
|
||||
let Some(g) = self.graph.as_mut() else { return };
|
||||
let inside = depth > g.frames;
|
||||
let Some(r) = g.live.as_mut() else { return };
|
||||
let tag = match vals {
|
||||
[target, column, row, axis] if hidden && r.stage == Stage::Done => {
|
||||
let (row, axis) = (int(row), int(axis));
|
||||
// A series is the whole column, measured on the left axis (2) or with no axis given (0):
|
||||
// the model keeps no series on a row or on the right axis.
|
||||
let whole_column = row == Some(0) && matches!(axis, Some(0 | 2));
|
||||
int(target).filter(|t| TARGETS.contains(t) && row.is_some() && axis.is_some()).zip(int(column)).map(|(t, c)| (t, c, whole_column))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let ok = match (tag, last) {
|
||||
(Some((SERIES, column, true)), Some((id, true))) if inside => usize::try_from(column).ok().map(|c| r.series.push((id, c))).is_some(),
|
||||
(Some((SERIES, ..)), _) => false,
|
||||
(Some(_), Some(_)) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !ok {
|
||||
self.graph_plain();
|
||||
}
|
||||
}
|
||||
|
||||
/// `GS`: the graph ends. Its drawn art is the one object its frame gained, a group; it keeps the
|
||||
/// graph's data and settings when they read whole (`whole`) and the art is registered on its plot.
|
||||
fn graph_end(&mut self, whole: bool) {
|
||||
let Some(g) = self.graph.take() else { return };
|
||||
let depth = self.frames.len();
|
||||
let live = g.live.filter(|r| whole && r.stage == Stage::Done && depth == g.frames);
|
||||
let spec = live.as_ref().and_then(|r| r.finish(&mut self.names).zip(r.series_tags()));
|
||||
let attached = spec.is_some_and(|(mut spec, tags)| {
|
||||
let Some(art) = self.frames.last_mut().filter(|f| f.children.len() == g.siblings + 1).and_then(|f| f.children.last_mut()) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(art.kind, NodeKind::Group { clip: false, .. }) || !tags_groups(art, &tags) || !draws_axes(art, spec.rect) {
|
||||
return false;
|
||||
}
|
||||
let Some(placed) = art.geometric_bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0) else { return false };
|
||||
let r = spec.rect;
|
||||
if r.x0 < placed.x0 - EDGE || r.y0 < placed.y0 - EDGE || r.x1 > placed.x1 + EDGE || r.y1 > placed.y1 + EDGE {
|
||||
return false;
|
||||
}
|
||||
let Some(paints) = series_paints(art, &tags, spec.kind == GraphKind::Line) else { return false };
|
||||
spec.series_paints = paints;
|
||||
spec.placed = Some(placed);
|
||||
tag_series(art, &tags);
|
||||
art.extras_mut().graph = Some(Box::new(spec));
|
||||
true
|
||||
});
|
||||
if !attached {
|
||||
self.warn(GRAPH_PLAIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
/// A graph's art: one series group (id 2) holding one mark painted `ap`, the group changed by
|
||||
/// `group`.
|
||||
fn art(ap: Appearance, group: impl Fn(&mut Node)) -> Node {
|
||||
let mark = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), ap));
|
||||
let mut series = Node::group(NodeId(2), vec![mark]);
|
||||
group(&mut series);
|
||||
Node::group(NodeId(1), vec![Arc::new(series)])
|
||||
}
|
||||
|
||||
/// A series whose mark has two fills has no one paint, so the graph can't draw it again.
|
||||
#[test]
|
||||
fn a_series_mark_with_two_fills_has_no_series_paint() {
|
||||
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let one = Appearance::basic(red.clone(), Paint::None, 0.0);
|
||||
let mut two = one.clone();
|
||||
two.items.extend(one.items.iter().find(|i| i.is_fill()).cloned());
|
||||
let tags = HashMap::from([(NodeId(2), 0)]);
|
||||
let kept = series_paints(&art(one, |_| {}), &tags, false).expect("one fill");
|
||||
assert_eq!((kept[0].fill.as_ref(), kept[0].stroke.as_ref()), (Some(&red), Some(&Paint::None)));
|
||||
assert!(series_paints(&art(two, |_| {}), &tags, false).is_none());
|
||||
}
|
||||
|
||||
/// A series group that is hidden, see-through or in another blending mode is not drawn again so.
|
||||
#[test]
|
||||
fn a_series_group_drawn_otherwise_has_no_series_paint() {
|
||||
let ap = || Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let tags = HashMap::from([(NodeId(2), 0)]);
|
||||
assert!(series_paints(&art(ap(), |g| g.visible = false), &tags, false).is_none(), "hidden");
|
||||
assert!(series_paints(&art(ap(), |g| g.opacity = 0.5), &tags, false).is_none(), "50% opaque");
|
||||
assert!(series_paints(&art(ap(), |g| g.blend = BlendMode::Multiply), &tags, false).is_none(), "Multiply");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
use super::containers::close;
|
||||
use super::document::slot_of;
|
||||
use super::types::{
|
||||
BLENDS, FLIP, Frame, GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, MAX_NODES, MAX_TAG_TEXT, MAX_TAGS, Place, URL_TAG,
|
||||
line_width,
|
||||
};
|
||||
use super::types::{BLENDS, FLIP, Frame, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, MAX_NODES, MAX_TAG_TEXT, MAX_TAGS, Place, URL_TAG, line_width};
|
||||
use super::{
|
||||
Affine, Appearance, AppearanceItem, Arc, BlendMode, BrushesKept, Dash, FillLayer, FillRule, Global, Gradient, GradientDef, Graph, HashMap,
|
||||
HashSet, Instance, Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, ShownNames, StrokeLayer, V, obj, paint,
|
||||
Affine, Appearance, AppearanceItem, Arc, BlendMode, BrushesKept, Dash, FillLayer, FillRule, Global, Gradient, GradientDef, HashMap, HashSet,
|
||||
Instance, Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, ShownNames, StrokeLayer, V, obj, paint,
|
||||
};
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
@@ -69,6 +66,7 @@ impl<'a> Reader<'a> {
|
||||
/// `LB`: a graph started in this layer that hasn't ended doesn't go on past it; the layer ends.
|
||||
fn layer_end(&mut self) -> Result<(), String> {
|
||||
if self.graph.as_ref().is_some_and(|g| g.frames >= self.frames.len()) {
|
||||
self.graph_plain();
|
||||
self.graph = None;
|
||||
self.warn(GRAPH_UNENDED);
|
||||
}
|
||||
@@ -158,7 +156,7 @@ impl<'a> Reader<'a> {
|
||||
let mesh_ended = std::mem::take(&mut self.mesh_ended);
|
||||
let vals = self.operands();
|
||||
let vals = self.take_head(vals);
|
||||
if self.graph_record(w, hidden) {
|
||||
if self.graph_record(w, &vals, hidden, (group_ended, mesh_ended)) {
|
||||
return Ok(());
|
||||
}
|
||||
// Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so
|
||||
@@ -428,33 +426,6 @@ impl<'a> Reader<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A graph's records (version 7 specification §17 and appendix A): `%_Gs` starts the graph on
|
||||
/// a layer, `GS` ends it, and its data and settings (the operators in [`GRAPH_OPS`], and any
|
||||
/// operator on a `%_` line between `Gt` and `GT`) are left out with their operands. Its drawn
|
||||
/// art, and anything else, is read as usual. `true` when `w` was one of them.
|
||||
fn graph_record(&mut self, w: &str, hidden: bool) -> bool {
|
||||
let Some(g) = self.graph.as_mut() else {
|
||||
// Outside a graph, its operators stay ones the reader doesn't know.
|
||||
if w == "Gs" && hidden && !self.frames.is_empty() {
|
||||
self.graph = Some(Graph { settings: false, frames: self.frames.len() });
|
||||
self.warn(GRAPH_PLAIN);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
match w {
|
||||
// A graph doesn't start inside another: the one being read goes on.
|
||||
"Gs" => self.warn(GRAPH_UNENDED),
|
||||
"GS" => self.graph = None,
|
||||
"Gt" => g.settings = true,
|
||||
"GT" => g.settings = false,
|
||||
_ if GRAPH_OPS.contains(&w) => {}
|
||||
_ if hidden && g.settings => {}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `… Lb`: a layer's options, `visible preview unlocked printing dimmed`, then the colour index
|
||||
/// and its RGB. The colour index is at 5 with fewer than ten operands, 6 with ten to twelve
|
||||
/// (`masks` before it) and 7 with thirteen or more (one more flag before it). Four places after
|
||||
@@ -588,9 +559,9 @@ impl<'a> Reader<'a> {
|
||||
return;
|
||||
}
|
||||
self.image_tail = false;
|
||||
// An object's tags (`XT`) 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") {
|
||||
// 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") {
|
||||
return;
|
||||
}
|
||||
self.after_object = false;
|
||||
@@ -1105,6 +1076,15 @@ fn no_guides(n: &Node, depth: usize) -> bool {
|
||||
depth <= MAX_DEPTH && !n.is_guide() && n.children().into_iter().flatten().all(|c| no_guides(c, depth + 1))
|
||||
}
|
||||
|
||||
/// Is `n` a compound path with a guide member whose bounds reach past the bounds of its members that
|
||||
/// aren't guides, or with guide members only? Any other object counts as no.
|
||||
fn guides_reach_past(n: &Node) -> bool {
|
||||
let NodeKind::Compound { children, .. } = &n.kind else { return false };
|
||||
let bounds =
|
||||
|guides: bool| children.iter().filter(|c| c.is_guide() == guides).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds()));
|
||||
bounds(true).is_some_and(|g| bounds(false).is_none_or(|o| o.union(g) != o))
|
||||
}
|
||||
|
||||
/// The largest Twist angle, in degrees either way, read as a live effect on an object that isn't a
|
||||
/// path or compound path with straight segments: VectorCraft's own limit. An object with a larger
|
||||
/// Twist keeps the file's drawn look.
|
||||
@@ -1118,7 +1098,8 @@ const MAX_STRAIGHT_TWIST: f64 = 180.0;
|
||||
/// Is `object` one that style `st`, whose effect moves the outline, is read on? It and everything
|
||||
/// in it [`moves_alike`], and the style paints no gradient. Twist is read only when nothing is
|
||||
/// stroked and its angle is a number: up to [`MAX_STRAIGHT_TWIST`] degrees either way on a path or
|
||||
/// compound path with straight segments, or up to [`MAX_TWIST`] degrees either way on another object
|
||||
/// compound path with straight segments (only at 0° when its guide members reach past its other
|
||||
/// members, see [`guides_reach_past`]), or up to [`MAX_TWIST`] degrees either way on another object
|
||||
/// with no guide in it. Transform that scales, turns or reflects is read only when nothing is stroked
|
||||
/// (its reference point lies on the bounds without strokes).
|
||||
fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
|
||||
@@ -1131,7 +1112,12 @@ fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
|
||||
&& st.effects.iter().all(|e| match e.id.as_str() {
|
||||
"distort.twist" => {
|
||||
let within = |limit: f64| e.params["angle"].as_f64().is_some_and(|a| a.abs() <= limit);
|
||||
!stroked && if straight(object) { within(MAX_STRAIGHT_TWIST) } else { no_guides(object, 0) && within(MAX_TWIST) }
|
||||
!stroked
|
||||
&& if straight(object) {
|
||||
within(MAX_STRAIGHT_TWIST) && (e.params["angle"].as_f64() == Some(0.0) || !guides_reach_past(object))
|
||||
} else {
|
||||
no_guides(object, 0) && within(MAX_TWIST)
|
||||
}
|
||||
}
|
||||
"distort.transform" => {
|
||||
let p = &e.params;
|
||||
@@ -1155,7 +1141,8 @@ fn image_warps(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
|
||||
|
||||
/// Does image `object` with `appearance` (its style's Warp) draw as `look`, its drawn look, compared
|
||||
/// over all that the warp draws ([`crate::import::ai::envelope::warped_alike`]) on `layer`, a layer
|
||||
/// id no other node has? Both are drawn shown, opaque and in the Normal mode. Unless the look is on
|
||||
/// id no other node has? Both are drawn shown, opaque and in the Normal mode; a hidden look is drawn
|
||||
/// with every object in it shown, as the hidden object's look holds them. Unless the look is on
|
||||
/// `%_` lines itself (`hidden`), the copies in it that are on `%_` lines (`hidden_ids`) are left out.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn image_warp_alike(
|
||||
@@ -1175,12 +1162,24 @@ fn image_warp_alike(
|
||||
if !hidden {
|
||||
strip(&mut plain, hidden_ids);
|
||||
}
|
||||
if !plain.visible {
|
||||
show_members(&mut plain);
|
||||
}
|
||||
for n in [&mut live, &mut plain] {
|
||||
(n.visible, n.opacity, n.blend) = (true, 1.0, BlendMode::Normal);
|
||||
}
|
||||
crate::import::ai::envelope::warped_alike(&live, &plain, layer, shared, page, spent)
|
||||
}
|
||||
|
||||
/// Show every object in container `n`, at any depth.
|
||||
fn show_members(n: &mut Node) {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
let c = Arc::make_mut(c);
|
||||
c.visible = true;
|
||||
show_members(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names.
|
||||
const PATTERN_KEY: &str = "\u{1}pattern ";
|
||||
|
||||
|
||||
@@ -436,8 +436,8 @@ pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content)
|
||||
/// The name of the group a `/ForeignObject`'s art comes in as.
|
||||
pub const NON_NATIVE_ART: &str = "Non-native art";
|
||||
|
||||
/// The note for a file with a graph (`%_Gs` … `%_GS`): its drawn art comes in, its data and
|
||||
/// settings don't.
|
||||
/// The note for a file with a graph (`%_Gs` … `%_GS`) whose data and settings can't be kept: its
|
||||
/// drawn art comes in as plain art.
|
||||
pub const GRAPH_PLAIN: &str = "graphs come in as plain art, without their data or settings";
|
||||
|
||||
/// The note for a graph that starts inside another or doesn't end before its layer does: the art
|
||||
@@ -445,18 +445,8 @@ pub const GRAPH_PLAIN: &str = "graphs come in as plain art, without their data o
|
||||
pub const GRAPH_UNENDED: &str = "a graph's records don't end where they should: its art was read as it is";
|
||||
|
||||
/// A graph's records (the version 7 specification's chapter 17 and appendix A): two-letter
|
||||
/// operators starting with `G`, read and left out with their operands.
|
||||
/// operators starting with `G`, read with their operands.
|
||||
pub(super) const GRAPH_OPS: &[&str] = &[
|
||||
"G1", "G+", "Ga", "GA", "Gb", "Gc", "GC", "Gd", "GD", "Ge", "Gf", "Gg", "Gi", "Gj", "Gl", "Gm", "Go", "Gp", "Gr", "Gs", "GS", "Gt", "GT", "Gv",
|
||||
"Gw", "GX", "Gx", "Gy", "Gz",
|
||||
];
|
||||
|
||||
/// A graph being read (`%_Gs` … `%_GS`).
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Graph {
|
||||
/// Between `Gt` and `GT`, where the graph's settings may use paint and type operators on `%_`
|
||||
/// lines too.
|
||||
pub(super) settings: bool,
|
||||
/// How many frames were open at its `Gs`: a layer that ends with fewer ends the graph.
|
||||
pub(super) frames: usize,
|
||||
}
|
||||
|
||||
@@ -7296,7 +7296,11 @@ fn twist_is_read_on_curves_compound_paths_and_groups_with_nothing_stroked_up_to_
|
||||
("a twisted group", read(&twist(90.0, None), &distort_group()), 2),
|
||||
("a group with a hidden member", read(&twist(45.0, None), &hidden_member), 2),
|
||||
("a curve twisted a quarter turn back", read(&twist(-90.0, BLUE_FILL), &curved), 0),
|
||||
("a straight compound path with a guide, as before", read(&twist(45.0, BLUE_FILL), &with_guide(&rect_path(20.0, 20.0, 60.0, 50.0))), 2),
|
||||
(
|
||||
"a straight compound path with a guide inside it, as before",
|
||||
read(&twist(45.0, BLUE_FILL), &with_guide(&rect_path(0.0, 0.0, 60.0, 50.0))),
|
||||
2,
|
||||
),
|
||||
] {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
@@ -7363,6 +7367,48 @@ fn twist_is_read_on_straight_paths_and_compound_paths_up_to_half_a_turn() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twist_on_a_straight_compound_path_keeps_the_drawn_look_where_a_guide_reaches_past_its_other_members() {
|
||||
// GUARDS: a straight compound path whose guide members reach past its other members was read
|
||||
// with a live Twist measured from bounds that take in those guides, which draws differently from
|
||||
// the art saved with it.
|
||||
// The object, `hidden` painted with its style, under its drawn look.
|
||||
let object = |hidden: &str| {
|
||||
format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{}0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0), hidden.replace('\n', "\n%_"))
|
||||
};
|
||||
// A compound path of a square and the guide from `from` to `to`.
|
||||
let with_guide = |from: (f64, f64), to: (f64, f64)| {
|
||||
object(&format!("*u\n{}f\n{} {} m\n{} {} L\n(N) *\n*U\n", rect_path(20.0, 20.0, 60.0, 50.0), from.0, from.1, to.0, to.1))
|
||||
};
|
||||
let twist = |angle: f64| distort_styles("Adobe Twirl", &format!("{angle} /Real (angle) ,\n"), BLUE_FILL);
|
||||
let read = |angle: f64, art_: &str| read_ok(&format!("{}{}", twist(angle), layer(art_)));
|
||||
for (what, r) in [
|
||||
("a guide inside the square", read(45.0, &with_guide((25.0, 25.0), (35.0, 35.0)))),
|
||||
("a guide on the square's corners", read(180.0, &with_guide((20.0, 20.0), (60.0, 50.0)))),
|
||||
("a guide inside the square, half a turn back", read(-180.0, &with_guide((30.0, 40.0), (50.0, 30.0)))),
|
||||
("a guide outside the square, not turned", read(0.0, &with_guide((5.0, 0.0), (15.0, 10.0)))),
|
||||
] {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{what}: {a:#?}");
|
||||
assert_eq!(a[0].children().map_or(0, |c| c.len()), 2, "{what}: the object, not its look: {a:#?}");
|
||||
let [e] = a[0].appearance.effects.as_slice() else { panic!("{what}: {:?}", a[0].appearance) };
|
||||
assert_eq!(e.id, "distort.twist", "{what}");
|
||||
}
|
||||
for (what, r) in [
|
||||
("a guide outside the square", read(45.0, &with_guide((5.0, 0.0), (15.0, 10.0)))),
|
||||
("a guide reaching past the square's right side", read(45.0, &with_guide((30.0, 30.0), (70.0, 40.0)))),
|
||||
("a guide reaching past the square's top, half a turn", read(180.0, &with_guide((30.0, 30.0), (40.0, 51.0)))),
|
||||
("a guide around the square, a quarter turn back", read(-90.0, &with_guide((0.0, 0.0), (80.0, 70.0)))),
|
||||
("a compound path of guides only", read(45.0, &object("*u\n25 25 m\n35 35 L\n(N) *\n40 25 m\n50 35 L\n(N) *\n*U\n"))),
|
||||
] {
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{what}: {a:#?}");
|
||||
assert!(a[0].appearance.effects.is_empty(), "{what}: the drawn look: {a:#?}");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() {
|
||||
// GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none.
|
||||
@@ -8786,7 +8832,8 @@ fn other_guide_paths_stay_guide_paths() {
|
||||
guide_line(0.0, 30.0, 200.0, 30.0, "S"),
|
||||
guide_line(-7885.0, -7796.0, 8498.0, 8587.0, "N"),
|
||||
format!("u\n{}U\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
|
||||
format!("1 Xw\n{}0 Xw\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
|
||||
format!("1 Xw\n{}0 Xw\n", guide_line(20.0, 40.0, 80.0, 40.0, "N")),
|
||||
format!("1 A\n{}0 A\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "S")),
|
||||
"-7885 40 m\n306 40 L\n8498 40 L\n(N) *\n".to_string(),
|
||||
];
|
||||
for body in cases {
|
||||
@@ -8796,6 +8843,35 @@ fn other_guide_paths_stay_guide_paths() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A hidden (`1 Xw`), locked (`1 A`) or named ruler guide path is a ruler guide that keeps its own
|
||||
/// visibility, lock and name, across the canvas or an artboard, on a shown layer.
|
||||
#[test]
|
||||
fn hidden_locked_and_named_guide_paths_are_ruler_guides() {
|
||||
let body = format!(
|
||||
"1 Xw\n{}0 Xw\n1 A\n{}0 A\n{}%_/ArtDictionary :\n%_(Mark) /String (AIArtName) ,\n%_;\n%_\n1 Xw\n{}0 Xw\n",
|
||||
guide_line(-7885.0, 40.0, 8498.0, 40.0, "N"),
|
||||
guide_line(25.0, 8587.0, 25.0, -7796.0, "N"),
|
||||
guide_line(-7885.0, 90.0, 8498.0, 90.0, "N"),
|
||||
guide_line(0.0, 30.0, 200.0, 30.0, "N"),
|
||||
);
|
||||
let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc;
|
||||
let top = &doc.layers[0];
|
||||
assert!(top.visible);
|
||||
let kids = top.children().unwrap();
|
||||
assert_eq!(kids.len(), 4);
|
||||
assert!(kids.iter().all(|c| c.is_ruler_guide()), "no guide path is left");
|
||||
let got: Vec<_> = doc.ruler_guides().into_iter().map(|g| (g.vertical, g.pos, g.artboard, g.layer, g.hidden, g.locked, g.name)).collect();
|
||||
let want = [
|
||||
(false, 60.0, None, Some(top.id), true, false, None),
|
||||
(true, 25.0, None, Some(top.id), false, true, None),
|
||||
(false, 10.0, None, Some(top.id), false, false, Some("Mark".to_string())),
|
||||
(false, 70.0, Some(1), Some(top.id), true, false, None),
|
||||
];
|
||||
assert_eq!(got, want);
|
||||
// The nodes themselves carry the state, so the Layers rows show it.
|
||||
assert_eq!(kids.iter().map(|k| (k.visible, k.locked)).collect::<Vec<_>>(), [(false, false), (true, true), (true, false), (false, false)]);
|
||||
}
|
||||
|
||||
/// The layer whose `Lb` has `ops` as its options (the first one in the file).
|
||||
fn layer_with(ops: &str) -> Node {
|
||||
let doc = read_ok(&format!("%AI5_BeginLayer\n{ops} Lb\n(L) Ln\nLB\n%AI5_EndLayer--\n")).doc;
|
||||
@@ -10547,6 +10623,7 @@ fn type_with_an_art_brush_keeps_its_drawn_look() {
|
||||
}
|
||||
|
||||
use read::{GRAPH_PLAIN, GRAPH_UNENDED};
|
||||
use vectorcraft_doc::{GraphKind, TickLength};
|
||||
|
||||
/// Editing data in the legacy format (version 7 specification, chapter 2): `%AI5_FileFormat 2.0`,
|
||||
/// the art size and the template box in the header, then `body`.
|
||||
@@ -10688,6 +10765,223 @@ fn a_graphs_records_take_their_operands() {
|
||||
assert_eq!(r.warnings, [GRAPH_PLAIN]);
|
||||
}
|
||||
|
||||
/// A graph's style record (`Gy`) for a grouped column graph: no shadow, legends as in column
|
||||
/// graphs, lines without marks, measured against the left axis.
|
||||
const LIVE_STYLE: &str = "%_5 0 0 14 0 1 0 0 6 44 Gy\n";
|
||||
|
||||
/// A graph's dialog record (`Gd`): columns 90% wide in clusters 80% wide.
|
||||
const LIVE_DIALOG: &str = "%_10 2 0 0 90 80 0 Gd\n";
|
||||
|
||||
/// A graph's axes, bottom, left and right: the bottom one with no tick marks, between its labels;
|
||||
/// the left one labeled `$` … ` units`, with two tick marks a division.
|
||||
const LIVE_AXES: &str = "%_1 () Ga\n%_0 13 0 0 0 0 1 () GA\n%_2 ($) Ga\n%_0 14 0 0 0 2 0 ( units) GA\n%_4 () Ga\n%_0 14 0 0 0 0 0 () GA\n";
|
||||
|
||||
/// A graph's table: series East and West, categories Q1 to Q3, and an empty cell (West in Q2).
|
||||
const LIVE_TABLE: &str = "%_4 3 1 1 Gz\n%_() Gc (East) Gc (West) Gc\n%_(Q1) Gc 5 3 (Q2) Gc 7 () Gc (Q3) Gc 4 6 Gc GC\n";
|
||||
|
||||
/// A column of a graph's series at `x`, tagged as one data bar.
|
||||
fn series_bar(x: f64) -> String {
|
||||
format!("0 0 0 1 k\n{}f\n%_4 1 0 0 Go\n", rect_path(x, 100.0, x + 20.0, 150.0))
|
||||
}
|
||||
|
||||
/// The first series' mark in [`live_art`].
|
||||
fn series_mark() -> String {
|
||||
format!("0 0 0 1 k\n{}f\n", rect_path(110.0, 100.0, 130.0, 150.0))
|
||||
}
|
||||
|
||||
/// [`series_mark`] filled and stroked, then an outline stroked alike with no fill.
|
||||
fn series_mark_and_outline() -> String {
|
||||
format!("0 0 0 1 k\n0 0 0 1 K\n{}B\n{}S\n", rect_path(110.0, 100.0, 130.0, 150.0), rect_path(112.0, 100.0, 118.0, 120.0))
|
||||
}
|
||||
|
||||
/// The drawn art of the graph [`live_graph`] gives: two series groups, each tagged as one series by
|
||||
/// its table column, and the axes on the plot's left and bottom edges.
|
||||
fn live_art() -> String {
|
||||
format!(
|
||||
"u\n{}U\n%_2 1 0 0 Go\nu\n{}U\n%_2 2 0 0 Go\nu\n0 0 0 1 K\n100 200 m\n100 100 L\n300 100 L\nS\nU\n%_9 0 0 2 Go\n",
|
||||
series_bar(110.0),
|
||||
series_bar(140.0)
|
||||
)
|
||||
}
|
||||
|
||||
/// A graph whose records read whole, written from the version 7 specification's appendix A, on the
|
||||
/// plot 100 100 300 200 (art space).
|
||||
fn live_graph() -> String {
|
||||
format!("%_Gs\n%_100 200 300 100 Gb\n{LIVE_STYLE}{LIVE_DIALOG}{LIVE_AXES}{LIVE_TABLE}u\n{}U\n%_GS\n", live_art())
|
||||
}
|
||||
|
||||
/// The graphs in `doc`'s art, and the series groups tagged in them.
|
||||
fn live_graphs(doc: &Document) -> (Vec<Node>, usize) {
|
||||
let (mut graphs, mut tagged) = (vec![], 0);
|
||||
doc.walk(|n| {
|
||||
if n.extras().graph.is_some() {
|
||||
graphs.push(n.clone());
|
||||
}
|
||||
tagged += usize::from(n.extras().series_index.is_some());
|
||||
});
|
||||
(graphs, tagged)
|
||||
}
|
||||
|
||||
fn same_rect(a: Rect, b: Rect) -> bool {
|
||||
(a.x0 - b.x0).abs() + (a.y0 - b.y0).abs() + (a.x1 - b.x1).abs() + (a.y1 - b.y1).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// A graph whose records read whole keeps its data and settings on its group, which holds its
|
||||
/// drawn art as it is, each series group tagged with its index.
|
||||
#[test]
|
||||
fn a_graph_whose_records_read_whole_keeps_its_data_and_settings() {
|
||||
let r = read(stream7(&layer7("Graph", &live_graph())).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1);
|
||||
let g = a[0].extras().graph.clone().expect("a live graph");
|
||||
assert_eq!(g.kind, GraphKind::Column);
|
||||
assert_eq!(g.series, ["East", "West"]);
|
||||
assert_eq!(g.categories, ["Q1", "Q2", "Q3"]);
|
||||
assert_eq!(g.cells(), [[Some(5.0), Some(3.0)], [Some(7.0), None], [Some(4.0), Some(6.0)]]);
|
||||
assert_eq!((g.column_width, g.cluster_width), (90.0, 80.0));
|
||||
assert_eq!((g.prefix.as_str(), g.suffix.as_str(), g.tick_length, g.tick_marks), ("$", " units", TickLength::Short, 2));
|
||||
assert_eq!((g.category_tick_length, g.ticks_between_labels), (TickLength::None, true));
|
||||
assert_eq!((g.ticks, g.axis_min, g.axis_max), (0, None, None));
|
||||
assert!(g.legend && !g.legend_across_top && !g.mark_points && g.connect_points && !g.drop_shadow && !g.edge_to_edge, "{g:?}");
|
||||
// The drawn art: two series groups and the axes, as written.
|
||||
let kids = a[0].children().unwrap();
|
||||
assert_eq!(kids.len(), 3);
|
||||
let index: Vec<_> = kids.iter().map(|k| k.extras().series_index).collect();
|
||||
assert_eq!(index, [Some(0), Some(1), None]);
|
||||
assert!(kids[..2].iter().all(|k| k.children().is_some_and(|c| c.len() == 1)));
|
||||
// Each series keeps its marks' fill, and no stroke, for when the graph is drawn again.
|
||||
assert_eq!(g.series_paints.len(), 2);
|
||||
let fill = kids[0].children().unwrap()[0].appearance.items.iter().find(|i| i.is_fill()).map(|i| i.paint().clone());
|
||||
assert!(g.series_paints.iter().all(|p| p.fill == fill && p.stroke.as_ref().is_some_and(|s| s.is_none())), "{:?}", g.series_paints);
|
||||
let axes = kids[2].children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!(same_rect(g.rect, axes), "the plot is where the axes are: {:?} {axes:?}", g.rect);
|
||||
assert_eq!(g.placed, a[0].geometric_bounds());
|
||||
}
|
||||
|
||||
/// The graph types the records give: 5 grouped column, 6 stacked column, 7 line, 10 area; and the
|
||||
/// style and dialog settings, each kept as the graph's.
|
||||
#[test]
|
||||
fn a_graphs_type_and_settings_come_from_its_records() {
|
||||
let live = |style: &str, dialog: &str, axes: &str| {
|
||||
let text = live_graph().replace(LIVE_STYLE, style).replace(LIVE_DIALOG, dialog).replace(LIVE_AXES, axes);
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{style}{dialog}: {:?}", r.warnings);
|
||||
art(&r.doc)[0].extras().graph.clone().expect("a live graph")
|
||||
};
|
||||
for (ty, kind) in [(5, GraphKind::Column), (6, GraphKind::StackedColumn), (7, GraphKind::Line), (10, GraphKind::Area)] {
|
||||
let g = live(&format!("%_{ty} 0 0 14 0 1 0 0 6 44 Gy\n"), LIVE_DIALOG, LIVE_AXES);
|
||||
assert_eq!(g.kind, kind, "{ty}");
|
||||
}
|
||||
let axes = LIVE_AXES.replace("%_0 14 0 0 0 2 0 ( units) GA", "%_1 15 0 100 25 0 0 () GA");
|
||||
let g = live("%_7 1 1 14 1 0 0 1 6 44 0 2 Gy\n", "%_10 2 1 0 120 50 1 Gd\n", &axes);
|
||||
assert!(g.drop_shadow && g.first_row_in_front && g.mark_points && !g.connect_points && g.legend_across_top, "{g:?}");
|
||||
assert!(g.first_column_in_front && g.edge_to_edge, "{g:?}");
|
||||
assert_eq!((g.column_width, g.cluster_width), (120.0, 50.0));
|
||||
assert_eq!((g.ticks, g.axis_min, g.axis_max, g.tick_length, g.tick_marks), (4, Some(0.0), Some(100.0), TickLength::Full, 1));
|
||||
// A line graph's series draws its line unfilled beside its filled marks.
|
||||
let text = live_graph().replace(LIVE_STYLE, "%_7 0 0 14 0 1 0 0 6 44 Gy\n").replacen(&series_mark(), &series_mark_and_outline(), 1);
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert!(art(&r.doc)[0].extras().graph.is_some());
|
||||
// An empty list of customizations, of the version appendix A gives (2), sets nothing.
|
||||
let text = live_graph().replace("GC\n", "GC\n%_2 Gt\n%_GT\n");
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert!(art(&r.doc)[0].extras().graph.is_some());
|
||||
}
|
||||
|
||||
/// A graph keeps its drawn art and stays plain, with one note, when a record has a shape or value
|
||||
/// appendix A doesn't give, sets what the model doesn't keep, or the table, the series tags or the
|
||||
/// art don't fit the graph.
|
||||
#[test]
|
||||
fn graphs_the_model_cant_keep_stay_plain_art() {
|
||||
let cases: Vec<(&str, String)> = vec![
|
||||
("an eleventh style value", live_graph().replace(LIVE_STYLE, "%_5 0 0 14 0 1 0 0 6 44 0 Gy\n")),
|
||||
("eleven dialog values", live_graph().replace(LIVE_DIALOG, "%_10 2 0 0 90 80 0 0 0 0 0 Gd\n")),
|
||||
("a fourth axis", live_graph().replace(LIVE_AXES, &format!("{LIVE_AXES}%_8 () Ga\n%_0 14 0 0 0 0 0 () GA\n"))),
|
||||
("ten axis values", live_graph().replace("%_0 14 0 0 0 0 0 () GA", "%_0 14 0 0 0 0 0 () 0 0 GA")),
|
||||
("a pie graph", live_graph().replace("%_5 0 0 14", "%_8 0 0 14")),
|
||||
("a scatter graph", live_graph().replace("%_5 0 0 14", "%_9 0 0 14")),
|
||||
("type 11", live_graph().replace("%_5 0 0 14", "%_11 0 0 14")),
|
||||
("legends in wedges", live_graph().replace("%_5 0 0 14", "%_5 0 0 15")),
|
||||
("the right axis", live_graph().replace("6 44 Gy", "6 45 Gy")),
|
||||
("lines as shapes", live_graph().replace("%_5 0 0 14 0 1 0", "%_5 0 0 14 0 1 1")),
|
||||
("both axes alike", live_graph().replace(LIVE_DIALOG, "%_10 2 0 1 90 80 0 Gd\n")),
|
||||
("a subscription", live_graph().replace(LIVE_DIALOG, &format!("{LIVE_DIALOG}%_0 0 1 1 1 Gj\n"))),
|
||||
("a customization", live_graph().replace("GC\n", "GC\n%_2 Gt\n%_(Bars) GD\n%_GT\n")),
|
||||
("customizations without their version", live_graph().replace("GC\n", "GC\n%_Gt\n%_GT\n")),
|
||||
("customizations of another version", live_graph().replace("GC\n", "GC\n%_3 Gt\n%_GT\n")),
|
||||
("bounds upside down", live_graph().replace("%_100 200 300 100 Gb", "%_100 100 300 200 Gb")),
|
||||
("a plot the axes aren't on", live_graph().replace("%_100 200 300 100 Gb", "%_50 200 300 100 Gb")),
|
||||
("a text data cell", live_graph().replace("7 () Gc", "(seven) Gc () Gc")),
|
||||
("a table of empty cells", live_graph().replace(LIVE_TABLE, "%_4 3 1 1 Gz\n%_() Gc (East) Gc (West) Gc\n%_GC\n")),
|
||||
("a cell past the table", live_graph().replace("4 6 Gc GC", "4 6 1 Gc GC")),
|
||||
("too many categories", live_graph().replace("%_4 3 1 1 Gz", "%_10002 3 1 1 Gz")),
|
||||
("a table too large", live_graph().replace("%_4 3 1 1 Gz", "%_4000000000 4000000000 1 1 Gz")),
|
||||
("a number as a label", live_graph().replace("(West) Gc", "2025 Gc")),
|
||||
("a series untagged", live_graph().replace("%_2 2 0 0 Go", "%_4 2 0 0 Go")),
|
||||
("a series tagged twice", live_graph().replace("%_2 2 0 0 Go", "%_2 1 0 0 Go")),
|
||||
("a path tagged as a series", live_graph().replacen("%_4 1 0 0 Go", "%_2 1 0 0 Go", 1)),
|
||||
("a target the specification doesn't list", live_graph().replace("%_9 0 0 2 Go", "%_3 0 0 2 Go")),
|
||||
("art before its group", live_graph().replace("GC\nu\n", &format!("GC\n{}f\nu\n", rect_path(0.0, 0.0, 5.0, 5.0)))),
|
||||
("a record on a line that shows", live_graph().replace("%_10 2 0 0 90 80 0 Gd", "10 2 0 0 90 80 0 Gd")),
|
||||
("series groups inside another group", live_graph().replace("GC\nu\n", "GC\nu\nu\n").replace("U\n%_GS", "U\nU\n%_GS")),
|
||||
(
|
||||
"two fills in one series",
|
||||
live_graph().replacen(
|
||||
"f\n%_4 1 0 0 Go\nU\n",
|
||||
&format!("f\n%_4 1 0 0 Go\n0 0 0 0.5 k\n{}f\nU\n", rect_path(112.0, 100.0, 118.0, 120.0)),
|
||||
1,
|
||||
),
|
||||
),
|
||||
("axes filled, not stroked", live_graph().replace("300 100 L\nS\n", "300 100 L\nf\n")),
|
||||
("a series tagged on a row", live_graph().replace("%_2 2 0 0 Go", "%_2 2 1 0 Go")),
|
||||
("a series tagged on the right axis", live_graph().replace("%_2 2 0 0 Go", "%_2 2 0 4 Go")),
|
||||
("an unfilled mark in a column series", live_graph().replacen(&series_mark(), &series_mark_and_outline(), 1)),
|
||||
("a series' mark in a group of its own", live_graph().replacen(&series_mark(), &format!("u\n{}U\n", series_mark()), 1)),
|
||||
(
|
||||
"an untagged graph of one series",
|
||||
live_graph()
|
||||
.replace(LIVE_TABLE, "%_2 2 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n")
|
||||
.replace("%_2 2 0 0 Go", "%_4 1 0 0 Go")
|
||||
.replace("%_2 1 0 0 Go", "%_4 1 0 0 Go"),
|
||||
),
|
||||
];
|
||||
for (what, text) in cases {
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(r.warnings, [GRAPH_PLAIN], "{what}");
|
||||
assert_eq!(live_graphs(&r.doc), (vec![], 0), "{what}");
|
||||
let mut kept = false;
|
||||
r.doc.walk(|n| kept |= n.children().is_some_and(|c| c.len() == 3));
|
||||
assert!(kept, "{what}: the drawn art is kept");
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph in a group or a clipping group keeps its data on its own group, not the container's; of
|
||||
/// two graphs in one layer, each is kept or left plain on its own, and the note is given once.
|
||||
#[test]
|
||||
fn graphs_are_kept_one_by_one_wherever_they_are() {
|
||||
let clip = format!("q\n{}W n\n{}Q\n", rect_path(0.0, 0.0, 400.0, 400.0), live_graph());
|
||||
for (what, text) in [("in a group", format!("u\n{}U\n", live_graph())), ("in a clipping group", clip)] {
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{what}: {:?}", r.warnings);
|
||||
let outer = &art(&r.doc)[0];
|
||||
assert!(outer.extras().graph.is_none(), "{what}");
|
||||
let (graphs, tagged) = live_graphs(&r.doc);
|
||||
assert_eq!((graphs.len(), tagged), (1, 2), "{what}");
|
||||
assert!(outer.children().unwrap().iter().any(|c| c.id == graphs[0].id), "{what}: the graph's own group");
|
||||
}
|
||||
let pie = live_graph().replace("%_5 0 0 14", "%_8 0 0 14");
|
||||
let body = format!("{}{pie}{}", live_graph(), live_graph());
|
||||
let r = read(stream7(&layer7("Graph", &body)).as_bytes()).unwrap();
|
||||
assert_eq!(r.warnings, [GRAPH_PLAIN]);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 3);
|
||||
let live: Vec<bool> = a.iter().map(|n| n.extras().graph.is_some()).collect();
|
||||
assert_eq!(live, [true, false, true]);
|
||||
}
|
||||
|
||||
/// Editing data in the legacy format (`%AI5_FileFormat 2.0`, ten `Lb` operands), then `body`.
|
||||
fn mask_stream7(body: &str) -> String {
|
||||
format!(
|
||||
|
||||
@@ -124,6 +124,7 @@ const STROKES: &str =
|
||||
"strokes a plain line can't draw (inside or outside the path, variable width, gradients, brushes, arrowheads) are written as filled outlines";
|
||||
const IMAGE_ALPHA: &str = "transparent image pixels are written over white";
|
||||
const LIVE: &str = "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art";
|
||||
const GRAPHS: &str = "graphs are written as their art, without their data or settings";
|
||||
/// The warning for an image left out because it can't be decoded: the note raster exports give
|
||||
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
|
||||
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
|
||||
@@ -2067,3 +2067,17 @@ fn colr_glyphs_of_outlined_type_are_vector_art() {
|
||||
assert!(rasters(&text).is_empty(), "no images: {text}");
|
||||
assert!(text.contains("1 0 0 Xa"), "the red layer: {text}");
|
||||
}
|
||||
|
||||
/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it.
|
||||
#[test]
|
||||
fn a_stained_instance_is_written_in_its_stained_color() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let art = shape(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), red(), Paint::None);
|
||||
doc.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
|
||||
let mut n = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((50.0, 30.0)) });
|
||||
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
|
||||
let l = doc.layers[0].id;
|
||||
doc.insert(Some(l), 0, n).unwrap();
|
||||
let text = String::from_utf8(write(&doc).bytes).unwrap();
|
||||
assert!(text.contains("0 0 1 Xa") && !text.contains("1 0 0 Xa"), "{text}");
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::{
|
||||
ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind,
|
||||
TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
|
||||
};
|
||||
use super::{CONIC, CONIC_WEDGES, DIAMOND, HUE_STOPS, SPREAD, SPREAD_CAPPED};
|
||||
use super::{CONIC, CONIC_WEDGES, DIAMOND, GRAPHS, HUE_STOPS, SPREAD, SPREAD_CAPPED};
|
||||
|
||||
pub(super) struct Writer<'a> {
|
||||
doc: &'a Document,
|
||||
@@ -254,6 +254,9 @@ impl<'a> Writer<'a> {
|
||||
if n.name.as_deref().is_some_and(|s| !s.is_empty()) && !n.is_layer() {
|
||||
self.warn(NAMES);
|
||||
}
|
||||
if n.extras().graph.is_some() {
|
||||
self.warn(GRAPHS);
|
||||
}
|
||||
let locked = locked || n.locked;
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, clip, .. } => {
|
||||
@@ -294,7 +297,7 @@ impl<'a> Writer<'a> {
|
||||
self.warn(LIVE);
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
&& let Some(mut art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
|
||||
&& let Some(mut art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n), *xf)
|
||||
{
|
||||
art.visible = true;
|
||||
self.nest += 1;
|
||||
|
||||
+5
-46
@@ -352,64 +352,23 @@ fn read_data(ps: &[u8], name: &str) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The magic number of an EPS file with a binary header (a preview).
|
||||
const DOS_MAGIC: [u8; 4] = [0xC5, 0xD0, 0xD3, 0xC6];
|
||||
/// The binary header's size.
|
||||
const DOS_HEADER: usize = 30;
|
||||
pub use craft_eps::{DOS_HEADER, DOS_MAGIC, Sections, is_eps};
|
||||
|
||||
/// `ps` behind a binary header, followed by the TIFF preview `tiff`.
|
||||
fn dos_eps(ps: &[u8], tiff: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let big = || "the file is too large for an EPS preview header (4 GB): choose no preview".to_string();
|
||||
let ps_len = u32::try_from(ps.len()).map_err(|_| big())?;
|
||||
let tiff_at = u32::try_from(DOS_HEADER + ps.len()).map_err(|_| big())?;
|
||||
let tiff_len = u32::try_from(tiff.len()).map_err(|_| big())?;
|
||||
tiff_at.checked_add(tiff_len).ok_or_else(big)?;
|
||||
let mut out = Vec::with_capacity(DOS_HEADER + ps.len() + tiff.len());
|
||||
out.extend(DOS_MAGIC);
|
||||
out.extend((DOS_HEADER as u32).to_le_bytes());
|
||||
out.extend(ps_len.to_le_bytes());
|
||||
// No metafile preview.
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out.extend(tiff_at.to_le_bytes());
|
||||
out.extend(tiff_len.to_le_bytes());
|
||||
// No checksum.
|
||||
out.extend(0xFFFFu16.to_le_bytes());
|
||||
out.extend(ps);
|
||||
out.extend(tiff);
|
||||
Ok(out)
|
||||
craft_eps::wrap(ps, Some(tiff), None).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The sections of an EPS file: its PostScript and its TIFF preview (binary header), or the whole
|
||||
/// file as PostScript. `None` when a binary header points outside the file.
|
||||
pub fn sections(bytes: &[u8]) -> Option<(&[u8], Option<&[u8]>)> {
|
||||
if !bytes.starts_with(&DOS_MAGIC) {
|
||||
return Some((bytes, None));
|
||||
}
|
||||
let word = |at: usize| bytes.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize);
|
||||
let part = |at: usize| {
|
||||
let (start, len) = (word(at)?, word(at + 4)?);
|
||||
bytes.get(start..start.checked_add(len)?)
|
||||
};
|
||||
let ps = part(4)?;
|
||||
let tiff = match word(24) {
|
||||
Some(0) | None => None,
|
||||
Some(_) => Some(part(20)?),
|
||||
};
|
||||
Some((ps, tiff))
|
||||
let s = craft_eps::sections(bytes).ok()?;
|
||||
Some((s.postscript, s.tiff))
|
||||
}
|
||||
|
||||
/// The Windows metafile preview behind an EPS file's binary header, if it has one.
|
||||
pub fn metafile_preview(bytes: &[u8]) -> Option<&[u8]> {
|
||||
if !bytes.starts_with(&DOS_MAGIC) {
|
||||
return None;
|
||||
}
|
||||
let word = |at: usize| bytes.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize);
|
||||
let (start, len) = (word(12)?, word(16)?);
|
||||
if start == 0 || len == 0 {
|
||||
return None;
|
||||
}
|
||||
bytes.get(start..start.checked_add(len)?)
|
||||
craft_eps::sections(bytes).ok()?.metafile
|
||||
}
|
||||
|
||||
/// The native document an EPS file written by [`export`] carries, if any.
|
||||
|
||||
+2
-92
@@ -21,9 +21,6 @@ VCdict begin
|
||||
end
|
||||
";
|
||||
|
||||
/// Longest line of encoded data (DSC readers take up to 255 characters).
|
||||
const LINE: usize = 76;
|
||||
|
||||
/// `v` as PostScript writes it: at most four decimals, no trailing zeros (0 when not finite).
|
||||
pub(crate) fn num(v: f64) -> String {
|
||||
let mut s = String::new();
|
||||
@@ -180,54 +177,7 @@ pub(crate) fn clip_op(rule: FillRule) -> &'static str {
|
||||
|
||||
/// `bytes` in ASCII85 with its `~>` end, in lines of about [`LINE`] characters, none starting
|
||||
/// with `%` (DSC readers would take it for a comment).
|
||||
pub(crate) fn ascii85(bytes: &[u8]) -> String {
|
||||
let mut raw = String::with_capacity(bytes.len() * 5 / 4 + 8);
|
||||
let (chunks, rest) = bytes.as_chunks::<4>();
|
||||
for c in chunks {
|
||||
let v = u32::from_be_bytes(*c);
|
||||
if v == 0 {
|
||||
raw.push('z');
|
||||
} else {
|
||||
push85(&mut raw, v, 5);
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
let mut c = [0u8; 4];
|
||||
for (d, s) in c.iter_mut().zip(rest) {
|
||||
*d = *s;
|
||||
}
|
||||
push85(&mut raw, u32::from_be_bytes(c), rest.len() + 1);
|
||||
}
|
||||
raw.push_str("~>");
|
||||
let mut out = String::with_capacity(raw.len() + raw.len() / LINE + 2);
|
||||
if raw.starts_with('%') {
|
||||
// Whitespace is ignored by readers, and keeps the first line from starting with `%`.
|
||||
out.push(' ');
|
||||
}
|
||||
let mut col = 0;
|
||||
for ch in raw.chars() {
|
||||
if col >= LINE && ch != '%' {
|
||||
out.push('\n');
|
||||
col = 0;
|
||||
}
|
||||
out.push(ch);
|
||||
col += 1;
|
||||
}
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
/// The first `n` base-85 digits of `v`.
|
||||
fn push85(s: &mut String, mut v: u32, n: usize) {
|
||||
let mut d = [0u8; 5];
|
||||
for slot in d.iter_mut().rev() {
|
||||
*slot = (v % 85) as u8 + b'!';
|
||||
v /= 85;
|
||||
}
|
||||
for b in d.iter().take(n) {
|
||||
s.push(char::from(*b));
|
||||
}
|
||||
}
|
||||
pub(crate) use craft_eps::ascii85::encode as ascii85;
|
||||
|
||||
/// Most bytes [`ascii85_decode`] makes: what one filter or data section may hold.
|
||||
pub(crate) const MAX_ASCII85: usize = 1 << 28;
|
||||
@@ -241,47 +191,7 @@ pub(crate) fn ascii85_decode(text: &str) -> Option<Vec<u8>> {
|
||||
/// [`ascii85_decode`] of at most `max` bytes: `z` is four bytes for one, so the length of the
|
||||
/// text doesn't bound what it becomes (#1715).
|
||||
pub(crate) fn ascii85_decode_within(text: &str, max: usize) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity((text.len() / 5 * 4).min(max));
|
||||
let mut group = [0u8; 5];
|
||||
let mut n = 0;
|
||||
let mut chars = text.bytes().filter(|b| !b.is_ascii_whitespace()).peekable();
|
||||
while let Some(b) = chars.next() {
|
||||
if out.len() > max {
|
||||
return None;
|
||||
}
|
||||
match b {
|
||||
b'~' => {
|
||||
if chars.next() != Some(b'>') {
|
||||
return None;
|
||||
}
|
||||
if n == 1 {
|
||||
return None;
|
||||
}
|
||||
if n > 1 {
|
||||
let padded: Vec<u8> = group.iter().take(n).copied().chain(std::iter::repeat(b'u')).take(5).collect();
|
||||
let v = value85(&padded)?;
|
||||
out.extend(v.to_be_bytes().iter().take(n - 1));
|
||||
}
|
||||
return (out.len() <= max).then_some(out);
|
||||
}
|
||||
b'z' if n == 0 => out.extend([0; 4]),
|
||||
b'!'..=b'u' => {
|
||||
*group.get_mut(n)? = b;
|
||||
n += 1;
|
||||
if n == 5 {
|
||||
out.extend(value85(&group)?.to_be_bytes());
|
||||
n = 0;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The value of five base-85 digits; `None` past `u32::MAX`.
|
||||
fn value85(digits: &[u8]) -> Option<u32> {
|
||||
digits.iter().try_fold(0u32, |acc, d| acc.checked_mul(85)?.checked_add(u32::from(d.checked_sub(b'!')?)))
|
||||
craft_eps::ascii85::decode(text.as_bytes(), max)
|
||||
}
|
||||
|
||||
/// Run-length encoding (PackBits: what PostScript's `RunLengthDecode` reads), shared with TIFF.
|
||||
|
||||
@@ -511,7 +511,7 @@ impl<'a> Scene<'a> {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
&& let Some(art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
|
||||
&& let Some(art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n), *xf)
|
||||
{
|
||||
self.nest += 1;
|
||||
self.node(&art, true);
|
||||
|
||||
@@ -1390,6 +1390,20 @@ fn an_instance_whose_art_reaches_the_region_is_written() {
|
||||
assert!(page.contains("1 0 0 rg"), "{page}");
|
||||
}
|
||||
|
||||
/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it.
|
||||
#[test]
|
||||
fn a_stained_instance_is_written_in_its_stained_color() {
|
||||
let mut d = doc_with(|d| {
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((50.0, 50.0)) });
|
||||
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
|
||||
vec![n]
|
||||
});
|
||||
let art = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
|
||||
let page = page_of(&d, &opts(&d));
|
||||
assert!(page.contains("0 0 1 rg") && !page.contains("1 0 0 rg"), "{page}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_whose_brush_art_reaches_the_region_is_written() {
|
||||
// The symbol's art: a line from (0, 40) to (0, 60) with a 1 pt red stroke painted by a 20 pt
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user