Merge main into fix/shortcut-optional-typed-params

This commit is contained in:
dotCooCoo
2026-11-13 22:58:54 -08:00
171 changed files with 7751 additions and 1511 deletions
Generated
+98 -28
View File
@@ -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",
+6
View File
@@ -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" }
+9
View File
@@ -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());
+28
View File
@@ -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>"]
+19
View File
@@ -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()
+3 -45
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+8 -4
View File
@@ -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));
+3 -3
View File
@@ -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.
+1 -1
View File
@@ -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));
+15
View File
@@ -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
+68
View File
@@ -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);
+7 -1
View File
@@ -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();
+50 -4
View File
@@ -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> {
+3 -2
View File
@@ -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
}
+3 -1
View File
@@ -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,
};
+40
View File
@@ -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};
+10 -3
View File
@@ -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),
}
+21
View File
@@ -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();
+9
View File
@@ -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> {
+3 -3
View File
@@ -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> {
+2 -2
View File
@@ -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> {
+1 -1
View File
@@ -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
),
+11 -3
View File
@@ -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());
}
+2 -2
View File
@@ -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
+17 -4
View File
@@ -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
+10 -5
View File
@@ -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")?;
+1 -1
View File
@@ -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,
};
+1 -1
View File
@@ -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
),
+1 -1
View File
@@ -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
)]
+13 -14
View File
@@ -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()?)?;
+69 -10
View File
@@ -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)
),
+87 -7
View File
@@ -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();
+2 -2
View File
@@ -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}");
}
+1 -1
View File
@@ -1724,7 +1724,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = json!({ "changed": changed, "swatches": added });
not_changed.report(&mut out);
if let Some(e) = edit {
e.defaults(s);
e.defaults(s)?;
out["relinked"] = json!(e.relinked());
}
if converted {
+1
View File
@@ -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 {
+40 -16
View File
@@ -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}))
}
+1 -1
View File
@@ -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 }))
}
+3 -4
View File
@@ -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
+5 -4
View File
@@ -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.
+4 -2
View File
@@ -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();
+119
View File
@@ -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, &params).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();
+19
View File
@@ -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]
+58
View File
@@ -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() {
+21 -1
View File
@@ -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]
+140
View File
@@ -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:?}");
}
}
+1
View File
@@ -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 }
+144 -11
View File
@@ -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", &params), 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();
+4
View File
@@ -206,6 +206,9 @@ pub(super) struct Writer<'a> {
mask_level: Option<(u32, u32)>,
/// The image just written was written as a placed file.
placed: bool,
/// The image being written is the copy of a styled object on the layers, on `%_` lines: linked,
/// it is written as a placed file there ([`Self::styled_object`]).
styled_copy: bool,
/// The type just written was written as type, not as its outlines.
typed: bool,
/// The objects bound to variables so far, in the order they are written: each one's id, its XML
@@ -299,6 +302,7 @@ impl<'a> Writer<'a> {
hidden: 0,
mask_level: None,
placed: false,
styled_copy: false,
typed: false,
bound: vec![],
texts: texts::TextDoc::default(),
+3 -2
View File
@@ -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);
+15 -8
View File
@@ -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 {
+2 -1
View File
@@ -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
+1 -2
View File
@@ -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(_), .. }))
+7 -2
View File
@@ -1445,7 +1445,8 @@ fn ruler_guide(a: Point, b: Point, centre: Option<(f64, f64)>, boards: &[Rect])
}
/// Makes the children of the layers and sublayers of `nodes` that `pick` makes a guide of into
/// ruler guide nodes with the same ids, moved to the top of their layer in their order.
/// ruler guide nodes with the same ids, names, locks and visibility, moved to the top of their layer
/// in their order.
fn take_ruler_guides(nodes: &mut [Arc<Node>], pick: &dyn Fn(NodeId) -> Option<Guide>, depth: usize) {
if depth > MAX_DEPTH {
return;
@@ -1458,7 +1459,11 @@ fn take_ruler_guides(nodes: &mut [Arc<Node>], pick: &dyn Fn(NodeId) -> Option<Gu
let mut guides = vec![];
kids.retain(|k| match pick(k.id) {
Some(g) => {
guides.push(Arc::new(Node::new(k.id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard })));
let mut n = Node::new(k.id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard });
n.name = k.name.clone();
n.locked = k.locked;
n.visible = k.visible;
guides.push(Arc::new(n));
false
}
None => true,
+14 -1
View File
@@ -1141,7 +1141,8 @@ fn image_warps(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
/// Does image `object` with `appearance` (its style's Warp) draw as `look`, its drawn look, compared
/// over all that the warp draws ([`crate::import::ai::envelope::warped_alike`]) on `layer`, a layer
/// id no other node has? Both are drawn shown, opaque and in the Normal mode. Unless the look is on
/// id no other node has? Both are drawn shown, opaque and in the Normal mode; a hidden look is drawn
/// with every object in it shown, as the hidden object's look holds them. Unless the look is on
/// `%_` lines itself (`hidden`), the copies in it that are on `%_` lines (`hidden_ids`) are left out.
#[allow(clippy::too_many_arguments)]
fn image_warp_alike(
@@ -1161,12 +1162,24 @@ fn image_warp_alike(
if !hidden {
strip(&mut plain, hidden_ids);
}
if !plain.visible {
show_members(&mut plain);
}
for n in [&mut live, &mut plain] {
(n.visible, n.opacity, n.blend) = (true, 1.0, BlendMode::Normal);
}
crate::import::ai::envelope::warped_alike(&live, &plain, layer, shared, page, spent)
}
/// Show every object in container `n`, at any depth.
fn show_members(n: &mut Node) {
for c in n.children_mut().into_iter().flatten() {
let c = Arc::make_mut(c);
c.visible = true;
show_members(c);
}
}
/// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names.
const PATTERN_KEY: &str = "\u{1}pattern ";
+31 -1
View File
@@ -8832,7 +8832,8 @@ fn other_guide_paths_stay_guide_paths() {
guide_line(0.0, 30.0, 200.0, 30.0, "S"),
guide_line(-7885.0, -7796.0, 8498.0, 8587.0, "N"),
format!("u\n{}U\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
format!("1 Xw\n{}0 Xw\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
format!("1 Xw\n{}0 Xw\n", guide_line(20.0, 40.0, 80.0, 40.0, "N")),
format!("1 A\n{}0 A\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "S")),
"-7885 40 m\n306 40 L\n8498 40 L\n(N) *\n".to_string(),
];
for body in cases {
@@ -8842,6 +8843,35 @@ fn other_guide_paths_stay_guide_paths() {
}
}
/// A hidden (`1 Xw`), locked (`1 A`) or named ruler guide path is a ruler guide that keeps its own
/// visibility, lock and name, across the canvas or an artboard, on a shown layer.
#[test]
fn hidden_locked_and_named_guide_paths_are_ruler_guides() {
let body = format!(
"1 Xw\n{}0 Xw\n1 A\n{}0 A\n{}%_/ArtDictionary :\n%_(Mark) /String (AIArtName) ,\n%_;\n%_\n1 Xw\n{}0 Xw\n",
guide_line(-7885.0, 40.0, 8498.0, 40.0, "N"),
guide_line(25.0, 8587.0, 25.0, -7796.0, "N"),
guide_line(-7885.0, 90.0, 8498.0, 90.0, "N"),
guide_line(0.0, 30.0, 200.0, 30.0, "N"),
);
let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc;
let top = &doc.layers[0];
assert!(top.visible);
let kids = top.children().unwrap();
assert_eq!(kids.len(), 4);
assert!(kids.iter().all(|c| c.is_ruler_guide()), "no guide path is left");
let got: Vec<_> = doc.ruler_guides().into_iter().map(|g| (g.vertical, g.pos, g.artboard, g.layer, g.hidden, g.locked, g.name)).collect();
let want = [
(false, 60.0, None, Some(top.id), true, false, None),
(true, 25.0, None, Some(top.id), false, true, None),
(false, 10.0, None, Some(top.id), false, false, Some("Mark".to_string())),
(false, 70.0, Some(1), Some(top.id), true, false, None),
];
assert_eq!(got, want);
// The nodes themselves carry the state, so the Layers rows show it.
assert_eq!(kids.iter().map(|k| (k.visible, k.locked)).collect::<Vec<_>>(), [(false, false), (true, true), (true, false), (false, false)]);
}
/// The layer whose `Lb` has `ops` as its options (the first one in the file).
fn layer_with(ops: &str) -> Node {
let doc = read_ok(&format!("%AI5_BeginLayer\n{ops} Lb\n(L) Ln\nLB\n%AI5_EndLayer--\n")).doc;
+14
View File
@@ -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}");
}
+1 -1
View File
@@ -297,7 +297,7 @@ impl<'a> Writer<'a> {
self.warn(LIVE);
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
&& let Some(mut art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
&& let Some(mut art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n), *xf)
{
art.visible = true;
self.nest += 1;
+5 -46
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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);
+14
View File
@@ -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
+7 -3
View File
@@ -284,7 +284,7 @@ fn save_file_warns_about_options_it_does_not_use() {
}
/// The notes on `options` name the `.ai` file the save writes, as the note on a top-level option
/// does: a legacy `.ai` for `aiVersion: "7"` (beside `pdfCompatible: false` too), a
/// does: a legacy `.ai` for `aiVersion: "7"` (beside `pdfCompatible: false` too, which it notes), a
/// non-PDF-compatible `.ai` for `pdfCompatible: false`, and a PDF-compatible `.ai` otherwise, also
/// when the save reuses the options the document was last saved with.
#[test]
@@ -301,8 +301,12 @@ fn save_file_notes_name_the_ai_file_it_writes() {
("pdf.ai", json!({"pdfCompatible": true, "preview": true}), "PDF-compatible .ai"),
] {
let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp(&dir, name), "version": 3, "options": options})));
let expected =
[format!("`version` was not used: {label} files don't read it"), format!("`options.preview` was not used: {label} files don't read it")];
// A legacy `.ai` doesn't read `pdfCompatible` either.
let unread = |k: &str| options.get(k).is_some() && (k != "pdfCompatible" || label.starts_with("legacy"));
let expected: Vec<String> = std::iter::once("version".to_string())
.chain(["pdfCompatible", "preview"].into_iter().filter(|k| unread(k)).map(|k| format!("options.{k}")))
.map(|k| format!("`{k}` was not used: {label} files don't read it"))
.collect();
assert_eq!(notes(&r), expected, "{name}: {r}");
}
// A save given no option the file reads writes it as last saved: a legacy `.ai` again.
+13 -1
View File
@@ -1465,7 +1465,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.action.new",
"New Action",
"",
"{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}",
"{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), the color of its button in Button Mode (ui.action.buttonMode). Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}",
),
(
"ui.action.options",
@@ -1479,6 +1479,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{action: name, set?: name} play the Actions panel's action named action at the speed ui.action.playbackOptions sets; set names the set to look in (default: every set). Accelerated plays it as one undoable batch → what command.batch returns. Step by Step and Pause For play its first step now and the others one at a time, each its own undo step, until the last, ui.action.stop, or a step that comes due while a different document is active than after the step before → {results: [the first step's result], pending: the steps left}. An action with a stop, at any speed, plays up to the stop and waits until ui.action.continue or ui.action.stop; at Accelerated the steps between stops are one undoable batch each → {results, pending, atStop?: true when it already waits}. A name no action has, or one that more than one action in the sets looked in has, or an action played while another is still playing step by step or with pauses, is an error and nothing plays",
),
(
"ui.action.playAt",
"Play Action",
"",
"{set: index, action: index} play the Actions panel's action at action in set (0 is the top set) as ui.action.play plays it, and return what ui.action.play returns. A click on an action's button in Button Mode runs this. A missing index, or one that is not a whole number or is past the last set or action, is an error and nothing plays",
),
(
"ui.action.buttonMode",
"Button Mode",
"",
"{enabled?: bool} with true, the Actions panel shows every action of every set, in set order and then action order, as a button with its name in its Action Options color (no color: an ordinary button), without set names, steps or checkmarks, and a click on a button plays its action (ui.action.playAt); with false, it shows its list again; left out or null, it switches to the other view. The panel menu's Button Mode runs this and shows a check mark while it is on. The view is kept with the UI preferences. Turning it on while an action records, or an enabled that is not true, false or null, is an error and the view stays → {enabled}",
),
(
"ui.action.playbackOptions",
"Playback Options",
+1 -1
View File
@@ -217,7 +217,7 @@ impl<'a> Scene<'a> {
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
&& let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n)
&& let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n)
&& let Some(art) =
if self.clips { self.doc.place_symbol_art(symbol, &art, *xf) } else { self.doc.place_symbol_art_unclipped(symbol, &art, *xf) }
{
+16
View File
@@ -399,6 +399,22 @@ fn an_instance_whose_art_reaches_the_artboard_is_written() {
assert!(k.contains(&39), "a brush fills the art: {k:?}");
}
/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it,
/// in EMF (clipped) and in WMF (unclipped).
#[test]
fn a_stained_instance_is_written_in_its_stained_color() {
let mut inst = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((30.0, 30.0)) });
inst.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
let mut d = doc_with(100.0, 100.0, vec![inst]);
let art = path(shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), red(), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
for clips in [true, false] {
let (ops, _) = crate::scene::Scene::new(&d, d.artboards[0].rect, clips).run();
let colors: Vec<[u8; 3]> = fills(&ops).into_iter().map(|(_, rgb)| rgb).collect();
assert_eq!(colors, [[0, 0, 255]], "clips: {clips}");
}
}
/// A pattern `name` repeating `art` every point.
fn point_tiles(name: &str, art: Node) -> PatternDef {
let mut def = PatternDef::new(name, vec![Arc::new(art)]);
+4 -3
View File
@@ -336,14 +336,15 @@ impl Exporter<'_> {
// instances multiply.
// Placing through 9-slice guides needs room for the most objects it can make
// (`NineSliceMap::most_placed`), and the objects it makes count.
// A dynamic symbol's instance shows its own appearance changes.
// A dynamic symbol's instance shows its own appearance changes, and the instance's
// fill stains its art, as on the canvas.
let doc = self.doc;
if let Some(sym) = doc.symbols.iter().find(|x| &x.name == symbol) {
let changed;
let art: &Node = if n.extras().symbol_overrides.is_empty() {
let art: &Node = if n.extras().symbol_overrides.is_empty() && vectorcraft_doc::symbol_overrides::stain(n).is_none() {
&sym.art
} else {
changed = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
changed = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n);
&changed
};
let count = art.count() as u64;
+65
View File
@@ -271,6 +271,71 @@ fn a_scaled_nine_slice_instance_keeps_its_corners() {
assert!(close(boxes[2], Rect::new(105.0, 85.0, 135.0, 105.0), 0.01), "{boxes:?}");
}
/// An instance of symbol `name` placed by `xf`, its fill red at `opacity` (shown when `visible`).
fn stained_instance(name: &str, xf: Affine, opacity: f32, visible: bool) -> Node {
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf });
n.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into();
n.appearance.items.retain(AppearanceItem::is_fill);
if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
(f.opacity, f.visible) = (opacity, visible);
}
n
}
/// The fill colors of `d`'s leaves, in paint order.
fn leaf_fills(d: &Document) -> Vec<String> {
leaves(d).iter().filter_map(|n| n.appearance.fill_paint().color()).map(|c| c.to_hex()).collect()
}
/// A stained instance is written in the colors the canvas draws it in
/// ([`vectorcraft_brush::instance_art`]): on its own, with a Transform effect that moves it, inside
/// a group, after a dynamic symbol's own fill change, and placed through 9-slice guides. A hidden
/// fill or one at 0% opacity leaves the art's colors as they are.
#[test]
fn a_stained_instance_keeps_its_stain() {
let blue = Color::rgb8(30, 90, 220);
let art = || Node::group(NodeId(0), vec![Arc::new(rect_node(Rect::new(0.0, 0.0, 100.0, 100.0), blue))]);
let canvas = |n: &Node| leaf_fills_of(&vectorcraft_brush::instance_art(&art(), n));
fn leaf_fills_of(n: &Node) -> Vec<String> {
let mut d = doc(10.0, 10.0);
add(&mut d, n.clone());
leaf_fills(&d)
}
let write = |n: Node, nine_slice: bool| {
let mut d = doc(300.0, 200.0);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: true, name: "Panel".into(), art: Arc::new(art()) });
if nine_slice {
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
}
add(&mut d, n);
leaf_fills(&roundtrip(&d))
};
let half = stained_instance("Panel", Affine::translate((20.0, 20.0)), 0.5, true);
let want = canvas(&half);
assert_ne!(want, [blue.to_hex()], "the stain changes the color");
assert_eq!(write(half.clone(), false), want, "on its own");
let mut moved = half.clone();
moved.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 30.0}),
visible: true,
});
assert_eq!(write(moved, false), want, "with a geometry effect");
assert_eq!(write(Node::group(NodeId(0), vec![Arc::new(half.clone())]), false), want, "in a group");
let mut scaled = half.clone();
scaled.kind = NodeKind::SymbolInstance { symbol: "Panel".into(), xf: Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.5) };
assert_eq!(write(scaled, true), want, "through 9-slice guides");
let mut changed = half.clone();
changed.extras_mut().symbol_overrides =
vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(Paint::solid(Color::rgb(0.0, 1.0, 0.0))), ..Default::default() }];
let changed_want = canvas(&changed);
assert_ne!(changed_want, want, "the part's own fill is stained");
assert_eq!(write(changed, false), changed_want, "after a dynamic symbol's change");
for (opacity, visible) in [(0.0, true), (0.5, false)] {
assert_eq!(write(stained_instance("Panel", Affine::IDENTITY, opacity, visible), false), [blue.to_hex()], "unstained");
}
}
/// The placed art of all the 9-slice instances in one export holds at most `MAX_SYMBOL_NODES`
/// objects, opacity-mask art included, as on the canvas: of two instances whose art placed through
/// the guides holds about 9,009,000 objects each (a gradient square in all nine cells, each copy
+2 -2
View File
@@ -110,8 +110,8 @@ pub(crate) struct PlacedEntry {
pub(crate) art: std::sync::Weak<Node>,
/// The art placed, or `None` for art placed again in every frame ([`depends_on_document`]).
pub(crate) placed: Option<Arc<Node>>,
/// The font generation ([`vectorcraft_text::FontDb::generation`]) the art was placed in, when
/// it holds type: the art is placed again when the fonts change.
/// The layout inputs ([`vectorcraft_text::layout_inputs`]) the art was placed with, when it
/// holds type: the art is placed again when the fonts or the hyphenation exceptions change.
pub(crate) fonts: Option<u64>,
/// The 9-slice map the art was placed through ([`Renderer::sliced_art`]), or `None` for art
/// placed by the transform alone ([`Renderer::placed_art`]). An entry is used only by the
+2 -2
View File
@@ -412,7 +412,7 @@ impl Renderer {
/// brush library it reads; for images and placed documents their pixels and files, the
/// layer's Dim Images and the options they are drawn with (with
/// [`crate::RenderOptions::kept_images_only`], also which pixels and undecodable notes the
/// image store holds); for type the fonts, whether overprinting shows
/// image store holds); for type the fonts and the hyphenation exceptions, whether overprinting shows
/// ([`crate::proof::overprints`]) and Document Setup's substitution highlights; and the
/// plug-ins installed, with the names of the document's swatches and patterns and its pattern
/// definitions (a plug-in's painted pieces can name them), when an object in it has a plug-in
@@ -457,7 +457,7 @@ impl Renderer {
f.doc.setup.highlight_substituted_glyphs,
o.highlight_alternate_glyphs,
));
(vectorcraft_text::FontDb::global().generation(), crate::proof::overprints(f.opts), highlights).hash(&mut h);
(vectorcraft_text::layout_inputs(), crate::proof::overprints(f.opts), highlights).hash(&mut h);
}
if reads.plugins || (doc.plugins && (reads.symbols || reads.patterns)) {
(effects::plugin_revision(), doc.names, doc.patterns).hash(&mut h);
+2 -2
View File
@@ -65,8 +65,8 @@ pub struct Renderer {
/// The latest cached glyph geometry for each text object id. A move changes the node's Arc
/// but not its glyphs in text space, so the old geometry can still be used after comparison.
text_ids: PtrMap<NodeId, usize>,
/// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out
/// again when fonts are added or rescanned.
/// The [`vectorcraft_text::layout_inputs`] `texts` was laid out with: type lays out again
/// when fonts are added or rescanned and when the hyphenation exceptions change.
text_fonts: u64,
/// Clip regions per clipping path (see [`Self::clip_of`]).
clips: FrameCache<usize, ClipEntry>,
+4 -3
View File
@@ -127,8 +127,9 @@ struct Walked {
}
impl Walked {
/// The [`resources_key`] of the node walked: the walk's hash, the font generation when it found
/// type or a placed file, the plug-in revision and the hash of the document's swatch and pattern
/// The [`resources_key`] of the node walked: the walk's hash, the layout inputs (fonts and
/// hyphenation exceptions, [`vectorcraft_text::layout_inputs`]) when it found type or a placed
/// file, the plug-in revision and the hash of the document's swatch and pattern
/// names (`names`) when it found a plug-in effect, and the color settings `cms`. Whether a
/// plug-in paints its pieces depends on the plug-ins installed, so the names count for every
/// plug-in effect.
@@ -136,7 +137,7 @@ impl Walked {
let mut h = DefaultHasher::new();
self.hash.hash(&mut h);
if self.text {
vectorcraft_text::FontDb::global().generation().hash(&mut h);
vectorcraft_text::layout_inputs().hash(&mut h);
}
if self.plugins {
crate::effects::plugin_revision().hash(&mut h);
+4 -3
View File
@@ -39,7 +39,8 @@ type TileKey = (String, u64, Ink, bool);
/// What a tile was drawn from besides its pattern's own art, for art that shows more than itself:
/// the art of every pattern and symbol and the pixels of every image in the document, the
/// document's other data (which holds its brushes), and the fonts. Editing a symbol leaves the art
/// document's other data (which holds its brushes), and the fonts and hyphenation exceptions
/// ([`vectorcraft_text::layout_inputs`]). Editing a symbol leaves the art
/// of a pattern that shows it as it was, so the art alone kept the tile from before (#1723). Held
/// weakly: a weak reference keeps the allocation, so no other value gets one of these addresses
/// while the tile is kept.
@@ -66,13 +67,13 @@ impl Deps {
art: Self::art(doc).map(Arc::downgrade).collect(),
pixels: Self::pixels(doc).map(Arc::downgrade).collect(),
unknown: vectorcraft_doc::Shared::downgrade(&doc.unknown),
fonts: vectorcraft_text::FontDb::global().generation(),
fonts: vectorcraft_text::layout_inputs(),
}
}
/// Does `doc` hold what the tile was drawn from?
fn current(&self, doc: &Document) -> bool {
self.fonts == vectorcraft_text::FontDb::global().generation()
self.fonts == vectorcraft_text::layout_inputs()
&& std::ptr::eq(self.unknown.as_ptr(), &*doc.unknown)
&& self.art.iter().map(Weak::as_ptr).eq(Self::art(doc).map(Arc::as_ptr))
&& self.pixels.iter().map(Weak::as_ptr).eq(Self::pixels(doc).map(Arc::as_ptr))
+5 -5
View File
@@ -55,7 +55,7 @@ impl Renderer {
/// objects) hit for them. Other art is placed again in every frame, and so is art that holds
/// symbol instances, type with inline graphics, images, placed documents or raster effects
/// ([`depends_on_document`]), and art that the cache has no room for. Art that holds type is
/// placed again when the fonts change.
/// placed again when the fonts or the hyphenation exceptions change.
pub(super) fn placed_art(&mut self, art: &Arc<Node>, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> (Arc<Node>, bool) {
let kept_kind = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || vectorcraft_doc::live::is_live(art);
// An instance with appearance changes of its own isn't kept: the key doesn't tell them apart.
@@ -67,7 +67,7 @@ impl Renderer {
let key = (Arc::as_ptr(art) as usize, xf.as_coeffs().map(f64::to_bits), scale_strokes, stain);
if let Some(e) = self.placed_arts.get(&key, self.stamp)
&& std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art))
&& e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation())
&& e.fonts.is_none_or(|g| g == vectorcraft_text::layout_inputs())
&& e.slice.is_none()
{
return match &e.placed {
@@ -106,7 +106,7 @@ impl Renderer {
if keeps
&& let Some(e) = self.placed_arts.get(&key, self.stamp)
&& std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art))
&& e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation())
&& e.fonts.is_none_or(|g| g == vectorcraft_text::layout_inputs())
&& e.slice.as_ref() == Some(map)
{
match e.placed.clone() {
@@ -134,7 +134,7 @@ impl Renderer {
fn keep_placed(&mut self, key: PlacedKey, art: &Arc<Node>, placed: &Arc<Node>, slice: Option<vectorcraft_doc::NineSliceMap>) -> bool {
let kept = (!depends_on_document(art)).then(|| placed.clone());
let keeps = kept.is_some();
let fonts = (keeps && holds_type(art)).then(|| vectorcraft_text::FontDb::global().generation());
let fonts = (keeps && holds_type(art)).then(vectorcraft_text::layout_inputs);
// The bytes count the key and the entry, the art's allocation that the weak reference
// keeps, and the kept art.
let bytes = size_of::<(PlacedKey, PlacedEntry, Node)>() + kept.as_deref().map_or(0, lru::node_bytes);
@@ -150,7 +150,7 @@ impl Renderer {
if !matches!(&a.kind, NodeKind::Text(own) if std::ptr::eq(&**own, t)) {
return (Arc::new(text_geom(t)), false);
}
let fonts = vectorcraft_text::FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs();
if fonts != self.text_fonts {
self.texts.clear();
self.text_ids.clear();
+78 -11
View File
@@ -3,16 +3,16 @@
use std::sync::{PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::text::{CharStyle, TextKind, TextObject};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Point;
use vectorcraft_geom::{PathData, Point};
use crate::*;
/// Taken to add fonts, or to look up an installed one by name (`FontDb::face` loads it, which
/// changes the fonts too). Renderers lay type out again when the fonts change, so tests of what
/// they keep between frames hold it shared ([`fonts_steady`]) and the tests that change the fonts
/// hold it alone ([`adding_fonts`]).
/// Taken to add fonts, to look up an installed one by name (`FontDb::face` loads it, which
/// changes the fonts too) or to change the hyphenation exceptions. Renderers lay type out again
/// when the fonts or the exceptions change, so tests of what they keep between frames hold it
/// shared ([`fonts_steady`]) and the tests that change them hold it alone ([`adding_fonts`]).
static FONTS: RwLock<()> = RwLock::new(());
/// No fonts are added by these tests until the guard is dropped.
@@ -29,14 +29,12 @@ pub(crate) fn adding_fonts() -> RwLockWriteGuard<'static, ()> {
/// What `step` returns from a run in which the fonts stayed the same. The fonts can change while
/// [`fonts_steady`] is held: the first lookup by family name catalogs the installed fonts, and
/// resolving a cataloged family, or a character no loaded font has, loads a face. `step` runs up
/// to 100 times, until [`vectorcraft_text::FontDb::generation`] is the same after a run as before
/// it.
/// to 100 times, until [`vectorcraft_text::layout_inputs`] is the same after a run as before it.
pub(crate) fn with_fonts_unchanged<T>(mut step: impl FnMut() -> T) -> T {
let db = vectorcraft_text::FontDb::global();
for _ in 0..100 {
let before = db.generation();
let before = vectorcraft_text::layout_inputs();
let out = step();
if db.generation() == before {
if vectorcraft_text::layout_inputs() == before {
return out;
}
}
@@ -466,6 +464,75 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() {
}
}
/// Hyphenated area type redraws with its new line breaks when Preferences › Hyphenation ›
/// Exceptions change: its glyph geometry, the pixels of its raster effects, the art drawn for
/// geometry effects, the art kept for a symbol instance and an envelope's evaluation are made
/// again.
#[test]
fn hyphenated_type_redraws_when_the_exceptions_change() {
let _adding = adding_fonts();
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
vectorcraft_text::set_hyphenation_exceptions("");
}
}
let _clear = Clear;
use vectorcraft_doc::Effect;
let moved = || Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true };
const WORDS: &str = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
const NEVER: &str = "extraordinarily, comprehensive, typographical, considerations, notwithstanding";
let cases = ["the area type", "the type with a drop shadow", "the group with a Transform effect", "the symbol instance", "the envelope"];
for (i, what) in cases.into_iter().enumerate() {
vectorcraft_text::set_hyphenation_exceptions("");
let mut d = Document::new(300.0, 150.0);
let mut t = TextObject::point(Point::ZERO, WORDS, CharStyle { size: 14.0, ..Default::default() });
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(10.0, 10.0, 130.0, 145.0).to_path(0.1)) };
t.xf = Affine::IDENTITY;
t.para.hyphenate = true;
let mut object = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
match i {
0 => {}
1 => {
let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100});
object.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true });
}
2 => {
object = Node::group(d.alloc_id(), vec![Arc::new(object)]);
object.appearance.effects.push(moved());
}
3 => {
object.appearance.effects.push(moved());
let art = Node::group(d.alloc_id(), vec![Arc::new(object)]);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Words".into(), art: Arc::new(art) });
object = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Words".into(), xf: Affine::IDENTITY });
}
_ => {
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(object)],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options: Default::default(),
frame: Box::new(Affine::IDENTITY),
};
object = Node::new(d.alloc_id(), envelope);
}
}
let l = d.layers[0].id;
d.insert(Some(l), 0, object).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
vectorcraft_text::set_hyphenation_exceptions(NEVER);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
let fresh = Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, fresh, "{what}: the words in the list are not broken");
assert!(after == fresh, "{what}: drawn as a new renderer draws it");
}
}
/// Without an engine session, a renderer maps an envelope of type from its glyph outline bounds,
/// and measures the type again when its font becomes available.
#[test]
+4 -4
View File
@@ -320,13 +320,13 @@ fn kept_glyphs(r: &mut Renderer, d: &Document) -> Vec<Option<Arc<TextGeom>>> {
.collect()
}
/// `f`'s result from a run during which the fonts did not change: other tests add fonts, and the
/// renderer lays all type out again when the fonts change.
/// `f`'s result from a run during which the fonts and the hyphenation exceptions did not change:
/// other tests change them, and the renderer lays all type out again when they change.
fn fonts_unchanged<T>(mut f: impl FnMut() -> T) -> T {
loop {
let fonts = vectorcraft_text::FontDb::global().generation();
let inputs = vectorcraft_text::layout_inputs();
let t = f();
if vectorcraft_text::FontDb::global().generation() == fonts {
if vectorcraft_text::layout_inputs() == inputs {
return t;
}
}
+1 -1
View File
@@ -859,7 +859,7 @@ fn type_outside_its_frame_is_drawn() {
fn a_pan_draws_the_type_from_the_kept_pixels() {
let _fonts = crate::tests_fontchange::fonts_steady();
let opts = RenderOptions::default();
let fonts = || vectorcraft_text::FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs;
// How many type objects a renderer drew offscreen for `b` in `second` after drawing `a` in
// `first`. The second frame's pixels are a new renderer's, each channel within `near`. The
// frames are drawn again while the fonts change between them (other tests load fonts, and the
+1
View File
@@ -9,6 +9,7 @@ description = "VectorCraft SVG import and export"
workspace = true
[dependencies]
craft-svg = { workspace = true }
# Which paths stored in documents may be opened.
craft-docpath = { workspace = true }
vectorcraft-geom = { workspace = true }
+3 -83
View File
@@ -2,90 +2,10 @@
//! replaces every `&name;` with the entity's text as it reads, with no total limit: a short file
//! that repeats a large entity in an attribute allocates far more than it holds.
use std::collections::HashMap;
/// The most text the references to entities may expand to in all (bytes).
const MAX_EXPANDED: usize = 16 << 20;
/// How deep entities that refer to entities are followed (the reader's own limit is lower).
const MAX_NESTING: u32 = 16;
/// The longest entity name a reference is looked for with ([`references`]).
const MAX_NAME: usize = 256;
/// The internal general entities declared in `svg`: name → every value declared for it. Text that
/// only looks like a declaration (in a comment, or in another entity's value) is kept too: the
/// largest value counts, so it can only make the estimate larger.
fn declared(svg: &str) -> HashMap<&str, Vec<&str>> {
let mut map: HashMap<&str, Vec<&str>> = HashMap::new();
for (at, _) in svg.match_indices("<!ENTITY") {
let rest = svg.get(at + "<!ENTITY".len()..).unwrap_or("").trim_start();
// A parameter entity (`%`) isn't referenced as `&name;`.
if rest.starts_with('%') {
continue;
}
let Some(end) = rest.find(char::is_whitespace) else { continue };
let (name, rest) = rest.split_at(end);
let rest = rest.trim_start();
let Some(quote) = rest.chars().next().filter(|q| matches!(q, '"' | '\'')) else { continue };
let Some(value) = rest.get(1..).and_then(|r| r.split_once(quote)).map(|(v, _)| v) else { continue };
map.entry(name).or_default().push(value);
}
map
}
/// The names of the `&name;` references in `text`.
fn references(text: &str) -> impl Iterator<Item = &str> {
text.match_indices('&').filter_map(|(at, _)| {
let rest = text.get(at + 1..)?;
// Look no further than the longest name: a file of `&`s without `;` would be read quadratically.
let end = rest.bytes().take(MAX_NAME + 1).position(|b| b == b';')?;
let name = rest.get(..end)?;
(!name.is_empty() && name.len() <= MAX_NAME && !name.starts_with('#') && !name.contains(char::is_whitespace)).then_some(name)
})
}
/// What entity `name` expands to (bytes), references in its value followed.
fn expanded<'a>(name: &'a str, entities: &HashMap<&'a str, Vec<&'a str>>, memo: &mut HashMap<&'a str, usize>, depth: u32) -> usize {
if let Some(n) = memo.get(name) {
return *n;
}
let Some(values) = entities.get(name) else { return 0 };
if depth >= MAX_NESTING {
return usize::MAX;
}
let mut most = 0usize;
for value in values {
let mut total = value.len();
for inner in references(value) {
total = total.saturating_add(expanded(inner, entities, memo, depth + 1));
}
most = most.max(total);
}
memo.insert(name, most);
most
}
/// Whether the references to the entities `svg` declares expand to more than [`MAX_EXPANDED`]
/// Whether the references to the entities `svg` declares expand to more than the limit
/// bytes in all. False for a file that declares none.
pub(crate) fn expansion_too_large(svg: &str) -> bool {
let entities = declared(svg);
if entities.is_empty() {
return false;
}
// A reference to a longer name isn't looked for: refuse such a declaration instead.
if entities.keys().any(|name| name.len() > MAX_NAME) {
return true;
}
let mut memo = HashMap::new();
let mut total = 0usize;
for name in references(svg) {
total = total.saturating_add(expanded(name, &entities, &mut memo, 0));
if total > MAX_EXPANDED {
return true;
}
}
false
craft_svg::guard::entities_expand_past(svg, craft_svg::guard::MAX_ENTITY_EXPANSION)
}
#[cfg(test)]
@@ -118,7 +38,7 @@ mod tests {
assert!(expansion_too_large(&commented));
let in_value = format!("<!DOCTYPE svg [<!ENTITY c \"<!ENTITY a 'x'>\"><!ENTITY a \"{big}\">]><svg id=\"{refs}\"/>");
assert!(expansion_too_large(&in_value));
let long = "n".repeat(MAX_NAME + 1);
let long = "n".repeat(257);
let long_name = format!("<!DOCTYPE svg [<!ENTITY {long} \"x\">]><svg id=\"&{long};\"/>");
assert!(expansion_too_large(&long_name));
}
+13 -64
View File
@@ -458,21 +458,17 @@ pub(crate) fn body_hash(parts: &[&str]) -> String {
/// Does `bytes` start like gzip data (an SVGZ file)?
pub fn is_svgz(bytes: &[u8]) -> bool {
bytes.starts_with(&[0x1f, 0x8b])
craft_svg::sniff::is_svgz(bytes)
}
/// Compress SVG text as SVGZ (gzip).
pub fn compress(svg: &str) -> Vec<u8> {
compress_bytes(svg.as_bytes())
craft_svg::svgz::compress(svg.as_bytes())
}
/// Compress an SVG file (in any encoding, see [`Output::bytes`]) as SVGZ (gzip).
pub fn compress_bytes(svg: &[u8]) -> Vec<u8> {
use std::io::Write as _;
let mut gz = flate2::write::GzEncoder::new(Vec::with_capacity(svg.len() / 4), flate2::Compression::default());
// Writing to a Vec cannot fail.
let _ = gz.write_all(svg);
gz.finish().unwrap_or_default()
craft_svg::svgz::compress(svg)
}
/// Most bytes an SVGZ file may unpack to (a guard against decompression bombs).
@@ -487,29 +483,20 @@ pub fn text_of(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, SvgError> {
/// [`text_of`], SVGZ bytes unpacking within what `unpacked`, the bytes SVGZ data unpacked to so
/// far, leaves of [`MAX_SVGZ`]. What they unpack to is added to it, whether or not it decodes.
pub(crate) fn text_within<'a>(bytes: &'a [u8], unpacked: &std::sync::atomic::AtomicU64) -> Result<std::borrow::Cow<'a, str>, SvgError> {
use std::io::Read as _;
use std::sync::atomic::Ordering;
if !is_svgz(bytes) {
return match decode(bytes)? {
Decoded::Utf8(skip) => std::str::from_utf8(bytes.get(skip..).unwrap_or_default()).map(Into::into).map_err(|_| not_utf8()),
Decoded::Text(t) => Ok(t.into()),
};
return craft_svg::svgz::decode(bytes).map_err(|_| not_utf8());
}
let most = MAX_SVGZ.saturating_sub(unpacked.load(Ordering::Relaxed));
let mut raw = Vec::new();
let read = flate2::read::GzDecoder::new(bytes).take(most.saturating_add(1)).read_to_end(&mut raw);
// Read before a failure is kept: it was unpacked too.
unpacked.fetch_add(raw.len() as u64, Ordering::Relaxed);
read.map_err(|e| SvgError::Parse(format!("not a valid SVGZ file: {e}")))?;
if raw.len() as u64 > most {
return Err(SvgError::TooBig(format!("the SVGZ file unpacks to more than {} MB", most >> 20)));
}
match decode(&raw)? {
Decoded::Utf8(skip) => {
raw.drain(..skip);
String::from_utf8(raw).map(Into::into).map_err(|_| not_utf8())
let most = usize::try_from(MAX_SVGZ.saturating_sub(unpacked.load(Ordering::Relaxed))).unwrap_or(usize::MAX);
match craft_svg::svgz::inflate(bytes, most) {
Ok(raw) => {
unpacked.fetch_add(raw.len() as u64, Ordering::Relaxed);
craft_svg::svgz::decode(&raw).map(|t| std::borrow::Cow::Owned(t.into_owned())).map_err(|_| not_utf8())
}
Decoded::Text(t) => Ok(t.into()),
Err(craft_svg::svgz::Error::TooLarge(_)) => Err(SvgError::TooBig(format!("the SVGZ file unpacks to more than {} MB", most >> 20))),
Err(craft_svg::svgz::Error::Corrupt(e)) => Err(SvgError::Parse(format!("not a valid SVGZ file: {e}"))),
Err(craft_svg::svgz::Error::Encoding) => Err(not_utf8()),
Err(e) => Err(SvgError::Parse(e.to_string())),
}
}
@@ -517,44 +504,6 @@ fn not_utf8() -> SvgError {
SvgError::Parse("SVG is not UTF-8, UTF-16 or ISO 8859-1".into())
}
/// How SVG bytes are read.
enum Decoded {
/// UTF-8 after this many bytes (a byte order mark).
Utf8(usize),
/// Text decoded from another encoding.
Text(String),
}
/// The encoding of SVG bytes: a byte order mark (UTF-8 or UTF-16), else the XML declaration's
/// (ISO 8859-1 by its names; anything else is read as UTF-8).
fn decode(bytes: &[u8]) -> Result<Decoded, SvgError> {
let utf16 = |rest: &[u8], unit: fn([u8; 2]) -> u16| {
let (units, odd) = rest.as_chunks::<2>();
if !odd.is_empty() {
return Err(not_utf8());
}
char::decode_utf16(units.iter().map(|u| unit(*u))).collect::<Result<String, _>>().map(Decoded::Text).map_err(|_| not_utf8())
};
match bytes {
[0xef, 0xbb, 0xbf, ..] => Ok(Decoded::Utf8(3)),
[0xfe, 0xff, rest @ ..] => utf16(rest, u16::from_be_bytes),
[0xff, 0xfe, rest @ ..] => utf16(rest, u16::from_le_bytes),
_ if declared_latin1(bytes) => Ok(Decoded::Text(bytes.iter().map(|b| char::from(*b)).collect())),
_ => Ok(Decoded::Utf8(0)),
}
}
/// Does the XML declaration at the start of `bytes` name ISO 8859-1?
fn declared_latin1(bytes: &[u8]) -> bool {
let head = bytes.get(..bytes.len().min(200)).unwrap_or_default();
let Some(decl) = head.strip_prefix(b"<?xml").and_then(|d| d.split(|b| *b == b'>').next()) else { return false };
let decl = String::from_utf8_lossy(decl).to_ascii_lowercase();
let Some(at) = decl.find("encoding") else { return false };
let value = decl.get(at + "encoding".len()..).unwrap_or("").trim_start().trim_start_matches('=').trim_start();
let name = value.trim_start_matches(['"', '\'']).split(['"', '\'', ' ', '?']).next().unwrap_or("");
matches!(name, "iso-8859-1" | "iso8859-1" | "iso_8859-1" | "latin1" | "latin-1" | "l1")
}
/// Import an SVG document.
pub fn import(svg: &str) -> Result<Document, SvgError> {
import_with_report(svg).map(|(d, _)| d)
+27
View File
@@ -272,6 +272,33 @@ fn instances_the_def_cant_stand_for_get_their_own_art() {
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 24.0, 0.002);
}
/// An instance half stained red whose Transform effect moves it 60 pt right is written in its
/// stained colors where the canvas draws it, on its own and inside a group.
#[test]
fn a_stained_instance_with_a_geometry_effect_keeps_its_stain() {
for grouped in [false, true] {
let mut d = symbol_doc(0.0, &[]);
let mut stained = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "My Mark".into(), xf: Affine::translate((40.0, 60.0)) });
stained.appearance = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0).into();
stained.appearance.items.retain(|i| i.is_fill());
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut stained.appearance.items[0] {
f.opacity = 0.5;
}
stained.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 60.0}),
visible: true,
});
let n = if grouped { Node::group(d.alloc_id(), vec![Arc::new(stained)]) } else { stained };
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, n).unwrap();
let svg = export(&d, &ExportOptions::default());
let blue = Color::rgb8(30, 90, 220).to_hex().to_ascii_lowercase();
assert!(!svg.to_ascii_lowercase().contains(&blue), "the symbol's own blue is stained: {svg}");
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 24.0, 0.003);
}
}
#[test]
fn pattern_paints_in_symbols_stay_on_the_page() {
let mut d = Document::new(200.0, 120.0);
+5 -5
View File
@@ -502,16 +502,16 @@ mod tests {
}
/// Process-wide store used by `prefs.set` / restore. Each change makes envelopes measure type
/// again.
/// again, and caches of layouts lay type out again.
#[test]
fn set_hyphenation_exceptions_round_trips_the_source_string() {
let inputs = crate::layout::envelope_ink_inputs();
let inputs = crate::layout_inputs();
set_hyphenation_exceptions("typography, hap-pen");
assert_eq!(hyphenation_exceptions(), "typography, hap-pen");
let changed = crate::layout::envelope_ink_inputs();
assert_ne!(changed, inputs, "the envelope inputs change with the exceptions");
let changed = crate::layout_inputs();
assert_ne!(changed, inputs, "the layout inputs change with the exceptions");
set_hyphenation_exceptions("");
assert!(hyphenation_exceptions().is_empty());
assert_ne!(crate::layout::envelope_ink_inputs(), changed, "and again when they are cleared");
assert_ne!(crate::layout_inputs(), changed, "and again when they are cleared");
}
}
+1 -3
View File
@@ -46,9 +46,7 @@ pub(crate) use driver::{fit_passes, layout_faces_per_call};
pub(crate) use on_path::{layout_trying_each_start, path_arrangements};
use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths};
#[cfg(test)]
pub(crate) use driver::envelope_ink_inputs;
pub use driver::{envelope_ink, register_envelope_ink};
pub use driver::{envelope_ink, layout_inputs, register_envelope_ink};
const EPS: f64 = 1e-6;
+5 -4
View File
@@ -191,12 +191,13 @@ pub fn envelope_ink(t: &TextObject, m: Affine, projection: Option<(vectorcraft_g
/// Make envelopes map type from [`envelope_ink`], measured again when the fonts or the hyphenation
/// exceptions change (the first registration stays, see `vectorcraft_doc::live::set_type_ink`).
pub fn register_envelope_ink() {
vectorcraft_doc::live::set_type_ink(envelope_ink, envelope_ink_inputs);
vectorcraft_doc::live::set_type_ink(envelope_ink, layout_inputs);
}
/// A number that changes whenever what [`envelope_ink`] reads besides the type changes: the
/// installed fonts and the hyphenation exceptions.
pub(crate) fn envelope_ink_inputs() -> u64 {
/// A number that changes when the fonts ([`FontDb::generation`]) or the hyphenation exceptions
/// (Preferences › Hyphenation › Exceptions) change. Caches of layouts made with the global font
/// database, and of art made from them, key on it.
pub fn layout_inputs() -> u64 {
FontDb::global().generation().rotate_left(32) ^ crate::hyphen::exceptions_generation()
}
+1 -1
View File
@@ -47,7 +47,7 @@ pub use fontdb::{
user_font_dirs,
};
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
pub use layout::{envelope_ink, register_envelope_ink};
pub use layout::{envelope_ink, layout_inputs, register_envelope_ink};
use std::sync::Arc;
pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
+57 -16
View File
@@ -4,7 +4,9 @@
//! dragging, ←/→ its second value or corner radius. A click selects a parametric shape and shows
//! its handles (centre, size, rotation, corner radius and one of the kind's own), whose drags run
//! `addon.pathtools.shape.set`; a double-click on the canvas places one with the last settings.
//! Each gesture is one undo step.
//! Each gesture is one undo step. A new shape's center (from a press or a double-click) and the
//! point dragged out from it snap as a drawing tool's points do ([`DrawSnap`]), hovering before
//! the press too.
//!
//! A parametric shape is an ordinary path that keeps its numbers in one entry of its object data
//! ([`KEY`]) with a fingerprint of the outline they drew. Moved, turned or scaled by any tool it
@@ -17,6 +19,7 @@ use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
use vectorcraft_pathops::addons::pathtools::shapes::fingerprint;
pub use vectorcraft_pathops::addons::pathtools::shapes::{MAX_COUNT, MAX_SIZE, MIN_COUNT, MIN_SIZE, ParametricShape, ShapeKind};
use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The tool's id.
@@ -209,7 +212,7 @@ fn angle(c: Point, p: Point, shift: bool) -> f64 {
#[derive(Clone, Debug, PartialEq)]
enum Drag {
/// Drawing a shape out from its centre.
Create { center: Point },
Create { center: Point, from: Point },
/// Dragging a handle of shape `id`, which was `shape` when the drag began at `from`.
Edit { id: NodeId, shape: ParametricShape, grip: Grip, from: Point },
}
@@ -221,11 +224,18 @@ pub struct ParametricShapeTool {
drag: Option<Drag>,
/// Where the pointer last was during the drag, and the keys held.
last: (Point, Mods),
/// Smart Guides for a new shape's center and the point dragged out from it.
snap: DrawSnap,
}
impl Default for ParametricShapeTool {
fn default() -> Self {
Self { next: ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0), drag: None, last: (Point::ZERO, Mods::default()) }
Self {
next: ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0),
drag: None,
last: (Point::ZERO, Mods::default()),
snap: DrawSnap::default(),
}
}
}
@@ -247,7 +257,7 @@ impl ParametricShapeTool {
fn shaped(&self, drag: &Drag, p: Point, mods: Mods) -> (&'static str, ParametricShape, Option<NodeId>) {
let fit = |v: f64| v.clamp(MIN_SIZE, MAX_SIZE);
match *drag {
Drag::Create { center } => {
Drag::Create { center, .. } => {
let r = center.distance(p);
let aspect = self.next.width / self.next.height;
let s = ParametricShape {
@@ -288,6 +298,18 @@ impl ParametricShapeTool {
}
Some(Action::Preview(cmd.into(), p))
}
/// Show the Smart Guides of a new shape's center at `p`, the pointer hovering; none over a
/// handle of the selected shape or over a parametric shape, where a press does not draw.
fn hover(&mut self, cx: &ToolContext, p: Point) {
let tol = cx.point_tol();
let on_grip = selected(cx).is_some_and(|(_, s)| grips(cx, &s).iter().any(|(_, at)| at.distance(p) <= tol));
if on_grip || shape_at(cx, p).is_some() {
self.snap.clear();
} else {
self.snap.hover(cx, p, &[], None);
}
}
}
impl Tool for ParametricShapeTool {
@@ -300,6 +322,9 @@ impl Tool for ParametricShapeTool {
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
if ev.kind == PointerKind::Move && self.drag.is_none() {
self.hover(cx, ev.pos);
}
match ev.kind {
PointerKind::Move => vec![],
PointerKind::Down => {
@@ -309,15 +334,17 @@ impl Tool for ParametricShapeTool {
near.min_by(|a, b| a.1.distance(ev.pos).total_cmp(&b.1.distance(ev.pos))).map(|(g, _)| (id, s, g))
});
if let Some((id, shape, grip)) = grip {
self.snap.clear();
self.drag = Some(Drag::Edit { id, shape, grip, from: ev.pos });
self.last = (ev.pos, ev.mods);
return vec![Action::Begin(EDIT.into())];
}
if let Some(id) = shape_at(cx, ev.pos) {
self.snap.clear();
return if cx.selection.objects == [id] { vec![] } else { vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))] };
}
let (center, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
self.drag = Some(Drag::Create { center });
let center = self.snap.press(cx, ev.pos, &[], None);
self.drag = Some(Drag::Create { center, from: ev.pos });
self.last = (center, ev.mods);
vec![Action::Begin(CREATE.into())]
}
@@ -325,15 +352,19 @@ impl Tool for ParametricShapeTool {
if self.drag.is_none() {
return vec![];
}
self.last = (ev.pos, ev.mods);
// The point a new shape is dragged out to snaps; a handle's drag does not.
let pos = if matches!(self.drag, Some(Drag::Create { .. })) { self.snap.drag(cx, ev.pos, None) } else { ev.pos };
self.last = (pos, ev.mods);
let Some(preview) = self.preview() else { return vec![] };
if ev.kind == PointerKind::Drag {
return vec![preview];
}
self.snap.clear();
let Some(drag) = self.drag.take() else { return vec![] };
match drag {
// A click draws nothing (a double-click places a shape).
Drag::Create { center } if center.distance(ev.pos) <= cx.tol(2.0) => vec![Action::Cancel],
// A click draws nothing (a double-click places a shape), even when its center
// snapped a few pixels from where it was pressed.
Drag::Create { from, .. } if from.distance(ev.pos) <= cx.tol(2.0) => vec![Action::Cancel],
Drag::Create { .. } => {
if let Action::Preview(_, p) = &preview
&& let Ok(s) = apply(self.next, p)
@@ -350,7 +381,8 @@ impl Tool for ParametricShapeTool {
if self.drag.is_some() || shape_at(cx, ev.pos).is_some() {
return vec![];
}
let s = ParametricShape { center: ev.pos, ..self.next };
let s = ParametricShape { center: self.snap.press(cx, ev.pos, &[], None), ..self.next };
self.snap.clear();
vec![Action::Exec("addon.pathtools.shape.create".into(), to_json(&s))]
}
}
@@ -358,6 +390,7 @@ impl Tool for ParametricShapeTool {
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.drag.take().is_some() {
self.snap.clear();
return vec![Action::Cancel];
}
if !matches!(self.drag, Some(Drag::Create { .. })) {
@@ -385,13 +418,16 @@ impl Tool for ParametricShapeTool {
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if let Some(Drag::Create { center }) = &self.drag {
let (_, s, _) = self.shaped(&Drag::Create { center: *center }, self.last.0, self.last.1);
if let Some(drag @ Drag::Create { .. }) = &self.drag {
let (_, s, _) = self.shaped(drag, self.last.0, self.last.1);
let text = format!("{}\nAngle: {:.1}°", cx.size_label(s.width, s.height), s.rotation);
return vec![Overlay::Measure { p: self.last.0 + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text }];
let mut out = self.snap.guides().to_vec();
out.push(Overlay::Measure { p: self.last.0 + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text });
return out;
}
let Some((_, s)) = selected(cx) else { return vec![] };
// The shape's box, then its handles.
// The hovering pointer's Smart Guides, then the selected shape's box and handles.
let mut out = self.snap.guides().to_vec();
let Some((_, s)) = selected(cx) else { return out };
let (rx, ry) = (s.width / 2.0, s.height / 2.0);
let mut bp = BezPath::new();
let f: Affine = s.frame();
@@ -400,7 +436,7 @@ impl Tool for ParametricShapeTool {
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
}
bp.close_path();
let mut out = vec![Overlay::Path { path: bp, color: BLUE, width: 1.0, dashed: true }];
out.push(Overlay::Path { path: bp, color: BLUE, width: 1.0, dashed: true });
for (grip, p) in grips(cx, &s) {
if grip == Grip::Rotate {
out.push(Overlay::Line { a: f * Point::new(0.0, -ry), b: p, color: BLUE, dashed: false });
@@ -418,6 +454,11 @@ impl Tool for ParametricShapeTool {
Cursor::ParametricShape
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.snap.clear();
vec![]
}
/// The next shape's numbers (its size and rotation are a double-click's).
fn options(&self) -> Value {
let mut v = to_json(&self.next);
+8
View File
@@ -695,6 +695,14 @@ impl Tool for ShapeBuilderTool {
self.finish_edit(cx)
}
/// The pointer hovers nothing for the tool: its highlight, the stroke a click would split and
/// the cursor swatch preview go, and a map that arrives afterwards highlights nothing.
fn hover_ended(&mut self, _cx: &ToolContext) {
self.hover = None;
self.stroke = None;
self.at = None;
}
fn poll(&mut self, cx: &ToolContext, wait: bool) -> Vec<Action> {
let mut acts = self.poll_merge(cx, wait);
if !acts.is_empty() || self.dragging {
+5
View File
@@ -769,6 +769,11 @@ impl Tool for LivePaintSelectionTool {
vec![]
}
/// The pointer hovers nothing for the tool: its highlight goes.
fn hover_ended(&mut self, _cx: &ToolContext) {
self.hover = LpHover::None;
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if let Some(r) = self.drag.and_then(|d| d.marquee(cx)) {
return vec![Overlay::Marquee(r)];
+52
View File
@@ -920,6 +920,43 @@ fn shape_builder_shift_drag_shows_no_scissors_or_stroke_highlight() {
assert_eq!(t.cursor(&cx, Point::new(75.0, 75.0), shift), Cursor::ShapeBuilder);
}
/// When the pointer leaves the canvas, the Shape Builder's region or stroke highlight and its
/// cursor swatch preview go.
#[test]
fn shape_builder_hover_ended_drops_the_highlight_and_the_swatch_preview() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
for (key, v) in [("pickColorFrom", json!("swatches")), ("cursorSwatchPreview", json!(true)), ("clickStrokeSplitsPath", json!(true))] {
t.set_option(key, &v);
}
// Over a region, then over a stroke a click would split.
for (x, y) in [(25.0, 25.0), (-1.0, 25.0)] {
t.pointer(&cx, &ev(PointerKind::Move, x, y));
let ov = t.overlays(&cx);
assert!(ov.iter().any(|o| matches!(o, Overlay::Swatch { .. })) && ov.iter().any(|o| matches!(o, Overlay::Path { .. })), "{ov:?}");
t.hover_ended(&cx);
assert!(t.overlays(&cx).is_empty());
}
}
/// A map of a large selection that arrives after the pointer leaves the canvas highlights nothing
/// under the point where the pointer was last seen.
#[test]
fn shape_builder_map_arriving_after_hover_ended_highlights_nothing() {
let (d, s) = square_row(40);
let p = paint();
let cx = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
let mut t = ShapeBuilderTool::default();
t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0));
assert!(t.waiting() && t.hover.is_none(), "the map is built on another thread");
t.hover_ended(&cx);
t.poll(&cx, true);
assert!(!t.waiting());
assert!(t.hover.is_none() && t.overlays(&cx).is_empty(), "{:?}", t.hover);
}
#[test]
fn shape_builder_stroke_options_round_trip_and_pick_the_side_under_the_pointer() {
let (d, s) = corner_rects();
@@ -1593,6 +1630,21 @@ fn live_paint_selection_hover_builds_no_map_of_the_selection() {
assert!(!t.waiting());
}
/// When the pointer leaves the canvas, the Live Paint Selection tool's highlight goes.
#[test]
fn live_paint_selection_hover_ended_drops_the_highlight() {
let (d, ..) = live_group_doc();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = LivePaintSelectionTool::default();
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
let ov = t.overlays(&cx);
assert!(ov.iter().any(|o| matches!(o, Overlay::Path { .. })), "{ov:?}");
t.hover_ended(&cx);
assert!(t.overlays(&cx).is_empty());
}
/// A Live Paint Selection click pressed with `down`: the press does nothing, and the release does
/// what is returned.
fn lp_click(t: &mut LivePaintSelectionTool, cx: &ToolContext, down: PointerEvent) -> Vec<Action> {
+5 -4
View File
@@ -453,6 +453,7 @@ group = "Navigation"
shown_when = [
"The Rotate, Reflect, Scale and Shear tools with nothing selected.",
"Puppet Warp off the mesh, where no pin can go.",
"The Smooth tool, and the Pencil or Blob Brush where Alt gives the Smooth tool, when no path is selected that the Smooth tool can act on: a drag changes nothing.",
]
hotspot = "The centre."
designer = false
@@ -690,10 +691,10 @@ look = "Only a small dot at the centre, as the Blob Brush's: the tip outline dra
id = "Smooth"
group = "Drawing and editing tools"
shown_when = [
"The Smooth tool: a drag along a selected path smooths it.",
"The Pencil tool with Alt key toggles to Smooth Tool on: with Alt held before a stroke begins to draw, and through a drag that smooths.",
"The Paintbrush tool with Alt held before a press while a path is selected that the Smooth tool can act on (an editable path, not a guide, with three or more anchors on one of its subpaths), and through a drag that smooths.",
"The Blob Brush tool with Alt held before a press, or before the drag has moved from the press point, and through a drag that smooths.",
"The Smooth tool while a path is selected that it can act on (an editable path, not a guide, with three or more anchors on one of its subpaths), wherever the pointer is: a drag along the path smooths it. With no such path selected, Not Allowed shows.",
"The Pencil tool with Alt key toggles to Smooth Tool on and such a path selected: with Alt held before a stroke begins to draw, and through a drag that smooths.",
"The Paintbrush tool with Alt held before a press while such a path is selected, and through a drag that smooths.",
"The Blob Brush tool with such a path selected: with Alt held before a press, or before the drag has moved from the press point, and through a drag that smooths.",
]
hotspot = "The tip."
+57 -1
View File
@@ -344,10 +344,19 @@ impl DimensionTool {
_ => Orientation::Aligned,
};
}
d.place(p);
// A dimension dragged out from art starts with placeholder points. Read the art's points
// before placing the dimension at the pointer. Clear the offset first: an out-of-range
// offset from the last pointer position fails the read.
if !matches!(d.mode, DimensionMode::Radius | DimensionMode::Diameter) {
d.offset = 0.0;
}
if d.resolve(cx.doc).is_err() {
return vec![];
}
d.place(p);
if d.validate().is_err() {
return vec![];
}
let mut actions = vec![];
if !self.began {
actions.push(Action::Begin("Create Dimension".into()));
@@ -675,6 +684,53 @@ mod tests {
assert!(matches!(placed.last(), Some(Action::Commit)), "{placed:?}");
}
/// A pointer position too far away to place the dimension shows no preview, and the preview
/// comes back where the pointer returns.
#[test]
fn placement_recovers_after_a_position_out_of_range() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = DimensionTool::default();
for (x, y) in [(400.0, 400.0), (450.0, 400.0)] {
t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y));
t.pointer(&c, &PointerEvent::new(PointerKind::Up, x, y));
}
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Move, 420.0, 3.0e6)).is_empty());
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Move, 420.0, 450.0));
let Some(Action::Preview(_, params)) = acts.last() else { panic!("{acts:?}") };
assert_eq!(params["offset"], json!(50.0));
}
/// Dragging out from a segment previews the dimension at the pointer from the first drag on.
#[test]
fn the_first_preview_of_a_segment_drag_is_at_the_pointer() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = DimensionTool::default();
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 100.0));
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 60.0));
let Some(Action::Preview(_, params)) = acts.last() else { panic!("{acts:?}") };
assert_eq!(params["offset"], json!(-40.0), "40 pt above the top edge");
}
/// Dragging out from a corner previews the angle at the pointer's distance from the corner
/// from the first drag on.
#[test]
fn the_first_preview_of_a_corner_drag_is_at_the_pointer() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = DimensionTool::default();
t.set_option("mode", &json!("angular"));
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 140.0));
let Some(Action::Preview(_, params)) = acts.last() else { panic!("{acts:?}") };
assert_eq!(params["vertex"], json!([100.0, 100.0]));
assert_eq!(params["offset"], json!(50.0), "50 pt from the corner");
}
/// A Linear press on an anchor picks that anchor even when the pointer moves a few pixels
/// before the release: it does not drag out the dimension of a segment that ends there. Both
/// picks keep their links to the path, so the dimension measures between the two anchors.
+16 -6
View File
@@ -777,7 +777,8 @@ fn has_selected_smooth_path(cx: &ToolContext) -> bool {
cx.selection.objects.iter().filter_map(|id| cx.doc.node(*id)).any(|n| {
let mut found = false;
n.walk(&mut |child| {
if matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.control_bounds().is_some() && path.subpaths.iter().any(smoothable))
if !found
&& matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.control_bounds().is_some() && path.subpaths.iter().any(smoothable))
&& editable(child.id)
{
found = true;
@@ -787,6 +788,13 @@ fn has_selected_smooth_path(cx: &ToolContext) -> bool {
})
}
/// The Smooth tool's cursor: Not Allowed when no selected path can be smoothed, since a drag then
/// changes nothing. A selected path anywhere on the canvas is enough, so the cursor does not depend
/// on where the pointer is.
fn smooth_cursor(cx: &ToolContext) -> Cursor {
if has_selected_smooth_path(cx) { Cursor::Smooth } else { Cursor::NotAllowed }
}
impl GestureTool {
pub fn new(id: &str) -> Self {
let id: &'static str = match id {
@@ -1173,7 +1181,7 @@ impl GestureTool {
let tol = cx.tol(self.edit_within);
let radius = cx.tol(SMOOTH_RADIUS);
if self.smoothing {
if has_sel && pts.len() >= 2 {
if pts.len() >= 2 && has_selected_smooth_path(cx) {
return vec![Action::Exec(
"path.smoothRegion".into(),
json!({"points": points_json(pts), "radius": radius, "fidelity": cx.tol(self.smooth_fidelity)}),
@@ -1225,7 +1233,7 @@ impl GestureTool {
}),
)],
"knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))],
"smooth" if has_sel && pts.len() >= 2 => {
"smooth" if pts.len() >= 2 && has_selected_smooth_path(cx) => {
vec![Action::Exec(
"path.smoothRegion".into(),
json!({"points": points_json(pts), "radius": radius, "fidelity": cx.tol(self.fidelity)}),
@@ -1534,13 +1542,15 @@ impl Tool for GestureTool {
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
// An active smoothing drag, or Alt before a press that would switch the Pencil, Paintbrush
// or Blob Brush to Smooth. A Paintbrush stroke already drawing keeps drawing with Alt.
// or Blob Brush to Smooth. A Paintbrush stroke already drawing keeps drawing with Alt. With
// no selected path to smooth, the Pencil and the Blob Brush show Not Allowed, as the Smooth
// tool does.
if self.smoothing
|| (self.id == "pencil" && self.alt_smooth && m.alt && !self.began)
|| (!self.active && self.id == "paintbrush" && m.alt && has_selected_smooth_path(cx))
|| self.blob_alt_smooths(m)
{
return Cursor::Smooth;
return smooth_cursor(cx);
}
// Before a press that would draw a straight line: over an end of a selected open path,
// the line continues that path from the end (`PointerKind::Down`).
@@ -1563,7 +1573,7 @@ impl Tool for GestureTool {
"blobBrush" => Cursor::BlobBrush,
"eraser" => Cursor::Eraser,
"knife" => Cursor::Knife,
"smooth" => Cursor::Smooth,
"smooth" => smooth_cursor(cx),
"pathEraser" => Cursor::PathEraser,
"join" => Cursor::Join,
// Before a press that draws a new path the Pencil shows its star; over a selected path

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