Merge main into fix/smooth-keeps-closed-start

This commit is contained in:
dotCooCoo
2026-11-13 22:58:44 -08:00
153 changed files with 7317 additions and 459 deletions
+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 -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
),
+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");
@@ -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:?}");
}
}
+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;
+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
+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
+125 -6
View File
@@ -369,7 +369,7 @@ fn the_freehand_tools_want_every_sample() {
#[test]
fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() {
let (d, _) = doc_with_rect();
let (d, rect) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
@@ -396,18 +396,26 @@ fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() {
}
// With Alt key toggles to Smooth Tool on, Alt lends the Pencil the Smooth tool instead, until
// a stroke has begun to draw: Alt then neither smooths nor closes, and the cursor stays the Pencil's.
// With nothing selected to smooth, the Smooth tool it lends shows Not Allowed.
let mut t = GestureTool::new("pencil");
t.set_option("altSmooth", &json!(true));
assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth);
assert_eq!(t.cursor(&cx, at, alt), Cursor::NotAllowed);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0));
assert_eq!(t.cursor(&cx, at, alt), Cursor::Pencil, "a stroke that has begun");
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt));
assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false), "{acts:?}");
// A smoothing drag keeps the Smooth cursor when Alt is let go.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt));
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::Smooth, "a smoothing drag");
// A smoothing drag keeps the lent cursor when Alt is let go: Smooth with a selected path, Not
// Allowed without one.
let mut selected = Selection::default();
selected.set([rect]);
for (sel, c) in [(&s, Cursor::NotAllowed), (&selected, Cursor::Smooth)] {
let cx = crate::testutil::cx(&d, sel, &p);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt));
assert_eq!(t.cursor(&cx, at, Mods::default()), c, "a smoothing drag");
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt));
}
}
#[test]
@@ -2531,6 +2539,117 @@ fn smooth_needs_selection_and_brackets_resize() {
assert_eq!(b.options()["size"], 11.0);
}
/// The Smooth tool shows Not Allowed while no selected path can be smoothed: nothing selected, only
/// type, a path of two anchors or a locked path. A selected path anywhere, alone or in a selected
/// group, gives the Smooth cursor wherever the pointer is, and the cursor follows the selection while
/// the tool stays chosen.
#[test]
fn smooth_shows_not_allowed_without_a_selected_path_to_smooth() {
use std::sync::Arc;
let (mut d, rect) = doc_with_rect();
let layer = d.layers[0].id;
let line_id = d.alloc_id();
let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![
vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)),
vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)),
]));
d.insert(Some(layer), 1, vectorcraft_doc::Node::path(line_id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 350.0, 350.0, 400.0));
let inner = vectorcraft_doc::Node::path(d.alloc_id(), square, vectorcraft_doc::Appearance::default_art());
let group = vectorcraft_doc::Node::group(d.alloc_id(), vec![Arc::new(inner)]);
let group_id = group.id;
d.insert(Some(layer), 2, group).unwrap();
let (text_doc, text) = doc_with_area_type();
let mut locked = d.clone();
locked.node_mut(rect).unwrap().locked = true;
let p = paint();
let sel = |ids: &[NodeId]| {
let mut s = Selection::default();
s.set(ids.iter().copied());
s
};
let t = GestureTool::new("smooth");
let far = Point::new(480.0, 20.0);
let cases: [(&vectorcraft_doc::Document, Selection, Cursor, &str); 7] = [
(&d, Selection::default(), Cursor::NotAllowed, "nothing selected"),
(&text_doc, sel(&[text]), Cursor::NotAllowed, "type"),
(&d, sel(&[line_id]), Cursor::NotAllowed, "a two-anchor path"),
(&locked, sel(&[rect]), Cursor::NotAllowed, "a locked path"),
(&d, sel(&[rect]), Cursor::Smooth, "a path away from the pointer"),
(&d, sel(&[line_id, rect]), Cursor::Smooth, "a path beside one it cannot smooth"),
(&d, sel(&[group_id]), Cursor::Smooth, "a group holding a path"),
];
for (doc, s, c, what) in &cases {
assert_eq!(t.cursor(&cx(doc, s, &p), far, Mods::default()), *c, "{what}");
}
// The cursor changes as the selection does, with the same tool.
let (none, one) = (Selection::default(), sel(&[rect]));
for (s, c) in [(&none, Cursor::NotAllowed), (&one, Cursor::Smooth), (&none, Cursor::NotAllowed)] {
assert_eq!(t.cursor(&cx(&d, s, &p), Point::new(150.0, 150.0), Mods::default()), c);
}
// A drag with Not Allowed showing sends nothing.
let mut t = GestureTool::new("smooth");
let acts = drag(&mut t, &cx(&d, &none, &p), &[(50.0, 150.0), (150.0, 150.0), (250.0, 150.0)], Mods::default());
assert!(acts.iter().all(|a| a.is_empty()), "{acts:?}");
}
/// The Blob Brush's Alt shows Not Allowed with no selected path to smooth, and the Smooth cursor with
/// one; the Paintbrush's Alt without one keeps the closing cursor, since its release closes the stroke.
#[test]
fn borrowed_smooth_shows_not_allowed_without_a_selected_path_to_smooth() {
let (d, rect) = doc_with_rect();
let none = Selection::default();
let mut one = Selection::default();
one.set([rect]);
let p = paint();
let alt = Mods { alt: true, ..Default::default() };
let at = Point::new(400.0, 400.0);
let blob = GestureTool::new("blobBrush");
assert_eq!(blob.cursor(&cx(&d, &none, &p), at, alt), Cursor::NotAllowed);
assert_eq!(blob.cursor(&cx(&d, &one, &p), at, alt), Cursor::Smooth);
assert_eq!(blob.cursor(&cx(&d, &none, &p), at, Mods::default()), Cursor::BlobBrush);
let mut pencil = GestureTool::new("pencil");
pencil.set_option("altSmooth", &json!(true));
assert_eq!(pencil.cursor(&cx(&d, &none, &p), at, alt), Cursor::NotAllowed);
assert_eq!(pencil.cursor(&cx(&d, &one, &p), at, alt), Cursor::Smooth);
let brush = GestureTool::new("paintbrush");
assert_eq!(brush.cursor(&cx(&d, &none, &p), at, alt), Cursor::PaintbrushClose);
assert_eq!(brush.cursor(&cx(&d, &one, &p), at, alt), Cursor::Smooth);
}
/// A Smooth release sends no command while the selection holds no path it can smooth, from the
/// Smooth tool and from the Blob Brush and Pencil with Alt; with a path selected it smooths.
#[test]
fn smoothing_release_sends_nothing_without_a_selected_path_to_smooth() {
let (text_doc, text) = doc_with_area_type();
let (d, rect) = doc_with_rect();
let mut type_only = Selection::default();
type_only.set([text]);
let mut one = Selection::default();
one.set([rect]);
let p = paint();
let alt = Mods { alt: true, ..Default::default() };
let stroke = [(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)];
let smooths = |acts: &[Vec<Action>]| acts.iter().flatten().any(|a| matches!(a, Action::Exec(c, _) if c == "path.smoothRegion"));
for (id, mods) in [("smooth", Mods::default()), ("blobBrush", alt), ("pencil", alt)] {
let tool = || {
let mut t = GestureTool::new(id);
if id == "pencil" {
t.set_option("altSmooth", &json!(true));
}
t
};
let mut c = cx(&text_doc, &type_only, &p);
c.smart_guides = false;
let acts = drag_held(&mut tool(), &c, &stroke, mods);
assert!(!smooths(&acts), "{id} with only type selected: {acts:?}");
let mut c = cx(&d, &one, &p);
c.smart_guides = false;
let acts = drag_held(&mut tool(), &c, &stroke, mods);
assert!(smooths(&acts), "{id} with a path selected: {acts:?}");
}
}
#[test]
fn pencil_alt_toggles_to_smooth_when_enabled_and_closes_when_disabled() {
let (d, id) = doc_with_rect();
+75
View File
@@ -714,6 +714,81 @@ fn pen_click_near_a_glyph_anchor_lands_on_it() {
assert!(q.distance(anchor) > 1e-3, "{q:?} landed on the glyph anchor with Smart Guides off");
}
/// Snap to Glyph: the Parametric Shape tool's center (a drag and a double-click) and the point
/// dragged out from it, and the Slice tool's corners, land on a glyph's anchor point as a Pen
/// click does, with the guides shown while hovering, and not with Snap to Glyph off.
#[test]
fn parametric_shape_and_slice_points_land_on_a_glyph_anchor() {
use crate::addons::parametric_shape::ParametricShapeTool;
use crate::slice::SliceTool;
use crate::{Action, PointerEvent, PointerKind, Tool};
let (d, t) = glyph_doc("H", 0.0);
let lay = layout_text(FontDb::global(), &t);
let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline");
// An anchor inside the glyph's bounds, which no glyph bounds line passes through.
let bb = g.outline.bounding_box();
let inside = |p: Point| p.x > bb.x0 + 1.0 && p.x < bb.x1 - 1.0 && p.y > bb.y0 + 1.0 && p.y < bb.y1 - 1.0;
let anchor = t.xf * g.outline.segments().map(|s| s.start()).find(|p| inside(*p)).expect("an anchor inside the glyph");
let (s, p) = (Selection::default(), paint());
let click = anchor + Vec2::new(1.5, -1.0);
let ev = |kind, q: Point| PointerEvent::new(kind, q.x, q.y);
let center = |v: &serde_json::Value| Point::new(v["center"][0].as_f64().unwrap(), v["center"][1].as_f64().unwrap());
// A press away from the type, for drags that end at the click.
let away = anchor + Vec2::new(-83.3, -61.7);
for glyphs in [true, false] {
let c = ToolContext { snap_to_glyph: glyphs, ..cx(&d, &s, &p) };
let lands = |q: Point| q.distance(anchor) < 1e-9;
// A Parametric Shape drawn out from the click.
let mut tool = ParametricShapeTool::default();
tool.pointer(&c, &ev(PointerKind::Down, click));
let acts = tool.pointer(&c, &ev(PointerKind::Drag, click + Vec2::new(80.0, 60.0)));
let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") };
assert_eq!(lands(center(v)), glyphs, "drag, Snap to Glyph {glyphs}: {:?} vs {anchor:?}", center(v));
// A Parametric Shape dragged out to the click is sized and turned as one dragged out to
// the anchor itself.
let drawn_to = |q: Point| {
let mut tool = ParametricShapeTool::default();
tool.pointer(&c, &ev(PointerKind::Down, away));
tool.pointer(&c, &ev(PointerKind::Drag, q))
};
assert_eq!(drawn_to(click) == drawn_to(anchor), glyphs, "dragged point, Snap to Glyph {glyphs}");
// A click 3 px from the anchor draws nothing, though its center snaps onto the anchor.
let near = anchor + Vec2::new(2.5, -1.5);
let mut tool = ParametricShapeTool::default();
tool.pointer(&c, &ev(PointerKind::Down, near));
let acts = tool.pointer(&c, &ev(PointerKind::Drag, near));
let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") };
assert_eq!(lands(center(v)), glyphs, "click, Snap to Glyph {glyphs}: {:?} vs {anchor:?}", center(v));
assert_eq!(tool.pointer(&c, &ev(PointerKind::Up, near)), vec![Action::Cancel], "click, Snap to Glyph {glyphs}");
// One placed with a double-click.
let mut tool = ParametricShapeTool::default();
let acts = tool.pointer(&c, &ev(PointerKind::DoubleClick, click));
let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") };
assert_eq!(lands(center(v)), glyphs, "double-click, Snap to Glyph {glyphs}: {:?} vs {anchor:?}", center(v));
// The pointer hovering at the click shows the guides of the anchor it would land on.
let mut tool = ParametricShapeTool::default();
tool.pointer(&c, &ev(PointerKind::Move, click));
assert_eq!(!tool.overlays(&c).is_empty(), glyphs, "hover, Snap to Glyph {glyphs}");
// A slice dragged from the click down and to the right starts at its top-left corner.
let mut tool = SliceTool::default();
tool.pointer(&c, &ev(PointerKind::Move, click));
assert_eq!(!tool.overlays(&c).is_empty(), glyphs, "slice hover, Snap to Glyph {glyphs}");
tool.pointer(&c, &ev(PointerKind::Down, click));
let acts = tool.pointer(&c, &ev(PointerKind::Drag, click + Vec2::new(80.0, 60.0)));
let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") };
let corner = Point::new(v["x"].as_f64().unwrap(), v["y"].as_f64().unwrap());
assert_eq!(lands(corner), glyphs, "slice, Snap to Glyph {glyphs}: {corner:?} vs {anchor:?}");
// One dragged from up and to the left ends at its bottom-right corner.
let mut tool = SliceTool::default();
tool.pointer(&c, &ev(PointerKind::Down, away));
let acts = tool.pointer(&c, &ev(PointerKind::Drag, click));
let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") };
let f = |k: &str| v[k].as_f64().unwrap();
let end = Point::new(f("x") + f("width"), f("y") + f("height"));
assert_eq!(end.distance(anchor) < 1e-6, glyphs, "slice end, Snap to Glyph {glyphs}: {end:?} vs {anchor:?}");
}
}
/// Snap to Glyph: a single line of type has an x-height guide; a paragraph shows glyph bounds only.
#[test]
fn drawn_points_line_up_with_the_x_height_of_a_single_line() {
+10 -2
View File
@@ -624,8 +624,16 @@ mod cursor_tests {
/// Use Precise Cursors turns it into the crosshair.
#[test]
fn drawing_tools_show_their_own_cursor() {
let (d, _) = doc_with_rect();
let (d, rect) = doc_with_rect();
let away = Point::new(400.0, 400.0);
// The Smooth tool shows its own cursor only with a selected path (Not Allowed without one).
let mut selected = Selection::default();
selected.set([rect]);
let paint = paint();
let cursor_of = |id: &str| match id {
"smooth" => create(id).cursor(&cx(&d, &selected, &paint), away, Mods::default()),
_ => cursor_at(id, &d, away),
};
for (id, c) in [
("lasso", Cursor::Lasso),
(addons::enclosing_lasso::ID, Cursor::Lasso),
@@ -651,7 +659,7 @@ mod cursor_tests {
("symbolStyler", Cursor::SymbolStyler),
("mesh", Cursor::MeshAdd),
] {
assert_eq!(cursor_at(id, &d, away), c, "{id}");
assert_eq!(cursor_of(id), c, "{id}");
assert_eq!(c.precise(), Cursor::Crosshair, "{id}");
// The Blob Brush's and the Eraser's dot is drawn: their tip outline goes round it. The
// Live Paint Bucket's bucket is drawn.
+44 -13
View File
@@ -14,7 +14,7 @@ use vectorcraft_doc::{NodeId, OrientedBox, SliceSource};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
use crate::guides::{PointSnap, Targets};
use crate::guides::{DrawSnap, PointSnap, Targets};
use crate::shape::drag_rect;
use crate::xform::{BLUE, polygon, rect_corners};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids};
@@ -37,9 +37,26 @@ fn has_area(r: Rect) -> bool {
r.width() > 1e-6 && r.height() > 1e-6
}
/// Snap `p` as the drawing tools do, then to the nearest ruler guide within reach (guides win).
fn snap(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
let (mut q, ov) = crate::guides::snap_draw(cx, p, &[]);
/// When the Slice tool snaps a point.
#[derive(Clone, Copy, PartialEq)]
enum Phase {
/// The pointer hovers before a press.
Hover,
/// The first corner is pressed: the targets are gathered afresh.
Press,
/// The other corner is dragged: the press's targets are kept.
Drag,
}
/// Snap `p` as the drawing tools do ([`DrawSnap`]), then to the nearest ruler guide within reach
/// (guides win, and the Smart Guides are then not shown).
fn snap(draw: &mut DrawSnap, cx: &ToolContext, p: Point, phase: Phase) -> (Point, Vec<Overlay>) {
let mut q = match phase {
Phase::Hover => draw.hover(cx, p, &[], None),
Phase::Press => draw.press(cx, p, &[], None),
Phase::Drag => draw.drag(cx, p, None),
};
let mut ov = draw.guides().to_vec();
let tol = cx.tol(5.0);
let guides = cx.doc.ruler_guides();
let nearest = |vertical: bool, v: f64| {
@@ -50,9 +67,11 @@ fn snap(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
};
if let Some(g) = nearest(true, p.x) {
q.x = g.pos;
ov.clear();
}
if let Some(g) = nearest(false, p.y) {
q.y = g.pos;
ov.clear();
}
(q, ov)
}
@@ -64,6 +83,8 @@ pub struct SliceTool {
last: Point,
began: bool,
guides: Vec<Overlay>,
/// Smart Guides for the slice's corners, and for the pointer hovering before a press.
snap: DrawSnap,
}
impl Tool for SliceTool {
@@ -77,14 +98,20 @@ impl Tool for SliceTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Move => {
if self.start.is_none() {
self.guides = snap(&mut self.snap, cx, ev.pos, Phase::Hover).1;
}
vec![]
}
PointerKind::Down => {
let (p, _) = snap(cx, ev.pos);
(self.start, self.last, self.began) = (Some(p), p, false);
let (p, guides) = snap(&mut self.snap, cx, ev.pos, Phase::Press);
(self.start, self.last, self.began, self.guides) = (Some(p), p, false, guides);
vec![]
}
PointerKind::Drag => {
let Some(start) = self.start else { return vec![] };
let (p, guides) = snap(cx, ev.pos);
let (p, guides) = snap(&mut self.snap, cx, ev.pos, Phase::Drag);
(self.last, self.guides) = (p, guides);
let mut out = vec![];
if !self.began {
@@ -102,6 +129,7 @@ impl Tool for SliceTool {
}
PointerKind::Up => {
self.guides.clear();
self.snap.clear();
let began = std::mem::take(&mut self.began);
if self.start.take().is_some() && began { vec![Action::Commit] } else { vec![] }
}
@@ -123,6 +151,8 @@ impl Tool for SliceTool {
}
o
}
// The hovering pointer's Smart Guides.
None => self.guides.clone(),
_ => vec![],
}
}
@@ -134,6 +164,7 @@ impl Tool for SliceTool {
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.start = None;
self.guides.clear();
self.snap.clear();
if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] }
}
}
@@ -152,8 +183,8 @@ impl Drag {
}
}
/// What a user slice's handle snaps to, gathered when it is pressed: what the Slice tool's drag
/// snaps to ([`snap`]), the art through [`PointSnap`] and the ruler guides on their own.
/// What a user slice's handle snaps to, gathered when it is pressed: the art's bounds through
/// [`PointSnap`], as a bounding-box handle snaps, and the ruler guides on their own.
struct ResizeSnap {
art: PointSnap,
rulers: Targets,
@@ -164,10 +195,10 @@ impl ResizeSnap {
Self { art: PointSnap::scaling(cx, || Targets::collect(cx.doc, &[], None)), rulers: Targets::rulers(cx) }
}
/// Snap the resize `a` ([`scale_for_drag`]) of the slice `rect` by `handle` as [`snap`] snaps
/// a drawn slice's corner: the handle snaps as a bounding-box handle does
/// ([`PointSnap::snap_scale`]), then a ruler guide within 5 px of where that leaves it takes
/// what the handle moves on its axis. Returns the scale and the guides showing why.
/// Snap the resize `a` ([`scale_for_drag`]) of the slice `rect` by `handle`: the handle snaps
/// as a bounding-box handle does ([`PointSnap::snap_scale`]), then a ruler guide within 5 px of
/// where that leaves it takes what the handle moves on its axis, as [`snap`] does for a drawn
/// slice's corner. Returns the scale and the guides showing why.
fn scale(&self, cx: &ToolContext, rect: Rect, handle: Handle, a: Affine, m: Mods) -> (Affine, Vec<Overlay>) {
let bx = OrientedBox::aligned(rect);
let (a, ov) = self.art.snap_scale(cx, &bx, handle, a, m.shift, m.alt);
+3 -3
View File
@@ -134,7 +134,7 @@ fn text_hash(s: &str) -> u64 {
h.finish()
}
/// The edited text's layout, keyed by the font database's generation and the text.
/// The edited text's layout, keyed by [`vectorcraft_text::layout_inputs`] and the text.
type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
/// Process-global Type-tool edit-session counter: the IME target's replacement epoch.
@@ -281,9 +281,9 @@ impl TypeTool {
}
/// Layout of `t`, cached by content (overlays run every frame) and by the fonts available (the
/// web build adds fonts after startup).
/// web build adds fonts after startup) and the hyphenation exceptions.
fn layout(&self, t: &TextObject) -> Arc<TextLayout> {
let fonts = FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs();
let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(((g, k), l)) = c.as_ref()
&& *g == fonts
+3 -2
View File
@@ -167,7 +167,8 @@ pub(crate) fn port_near(cx: &ToolContext, p: Point) -> Option<(NodeId, Port, f64
}
/// Which selected text overflows, for [`thread_ports`]: each text object's layout verdict kept
/// with the object and the fonts it was made from, as overlays run every frame.
/// with the object and the layout inputs it was made with (fonts and hyphenation exceptions,
/// [`vectorcraft_text::layout_inputs`]), as overlays run every frame.
#[derive(Default)]
pub struct PortMarks {
seen: std::collections::HashMap<NodeId, ((u64, TextObject), bool)>,
@@ -178,7 +179,7 @@ pub struct PortMarks {
/// port whether text is hidden by the size of its frame, whatever tool is active. None when more
/// than [`PORTS_MAX`] of them are selected.
pub fn thread_ports(marks: &mut PortMarks, cx: &ToolContext) -> Vec<Overlay> {
let fonts = FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs();
let mut o = Vec::new();
let mut kept = std::collections::HashMap::new();
if !ports_shown(cx.doc, &cx.selection.objects) {
+30
View File
@@ -1538,6 +1538,36 @@ fn selected_type_shows_its_ports_in_each_state() {
assert!(thread_ports(&mut marks, &cx(&d, &sel, &p)).is_empty(), "only selected type shows them");
}
/// The out port shows whether the text fits as the hyphenation exceptions lay it out now: when
/// Preferences › Hyphenation › Exceptions change, the selected type is laid out again.
#[test]
fn the_out_port_follows_the_hyphenation_exceptions() {
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
vectorcraft_text::set_hyphenation_exceptions("");
}
}
let _clear = Clear;
const WORDS: &str = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
const NEVER: &str = "extraordinarily, comprehensive, typographical, considerations, notwithstanding";
let (mut d, id) = crate::testutil::doc_with_area_type();
// A 70 by 170 pt frame holds the hyphenated text, and the text without those breaks overflows
// it.
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") };
t.runs = vec![TextRun::new(WORDS, CharStyle { size: 14.0, ..Default::default() })];
t.para.hyphenate = true;
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 70.0, 170.0).to_path(0.1)) };
let (mut sel, p) = (Selection::default(), paint());
sel.set([id]);
let mut marks = PortMarks::default();
let red = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Path { color: [230, 30, 30], .. })).count();
vectorcraft_text::set_hyphenation_exceptions("");
assert_eq!(red(&thread_ports(&mut marks, &cx(&d, &sel, &p))), 0, "the hyphenated text fits");
vectorcraft_text::set_hyphenation_exceptions(NEVER);
assert_eq!(red(&thread_ports(&mut marks, &cx(&d, &sel, &p))), 1, "the text without those breaks does not");
}
/// Past [`PORTS_MAX`] selected frames (select all of an opened PDF) no ports are drawn or clicked:
/// they would bury the canvas in boxes; up to it every frame has its two.
#[test]
+4 -3
View File
@@ -99,7 +99,7 @@ struct Drag {
began: bool,
}
/// The type object's layout, keyed by the font database's generation and the object.
/// The type object's layout, keyed by [`vectorcraft_text::layout_inputs`] and the object.
type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
#[derive(Default)]
@@ -196,9 +196,10 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option<CharBox> {
}
impl TouchTypeTool {
/// Layout of `t`, cached by content (overlays run every frame) and by the fonts available.
/// Layout of `t`, cached by content (overlays run every frame), by the fonts available and by
/// the hyphenation exceptions.
fn layout(&self, t: &TextObject) -> Arc<TextLayout> {
let fonts = FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs();
let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(((g, k), l)) = c.as_ref()
&& *g == fonts
+1
View File
@@ -136,6 +136,7 @@ impl VectorcraftApp {
custom_titlebar: false,
caption_layout: Default::default(),
desktop_frame: false,
desktop_keeps_frame: false,
last_window_title: String::new(),
fullscreen_for_mode: false,
graphics_adapter: None,
+36 -1
View File
@@ -22,7 +22,7 @@ pub(super) fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
});
}
/// The Home screen's column: welcome, New / Open, Recent Files, presets and community links.
/// The Home screen's column: welcome, New / Open, Recent Files, Learn, presets and community links.
fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// The welcome and the New / Open buttons sit centred, as PhotoCraft's Home screen does (#4684).
@@ -46,6 +46,7 @@ fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) {
});
});
recent(app, ui);
learn(app, ui);
ui.add_space(28.0);
ui.label(egui::RichText::new(tl!("Quickly start a new file")).font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
@@ -68,6 +69,40 @@ fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) {
});
}
/// The id of the Home screen's Quick skill guide card.
const LEARN_GUIDE_ID: &str = "home-quick-skill-guide";
/// The Home screen's Learn section: the Quick skill guide card, which shows the Learn panel's list
/// of skills, and the Learn panel's tutorials under their skills, each a button that shows the
/// panel at its steps (`learn.show`). Neither opens, closes or switches a document, and the panel
/// shows beside the Home screen.
fn learn(app: &mut VectorcraftApp, ui: &mut Ui) {
use crate::panels::learn::{catalog, guide_card};
let t = Tokens::get(ui.ctx());
ui.add_space(28.0);
ui.label(egui::RichText::new(tl!("Learn")).font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
guide_card(app, ui, egui::Id::unique(LEARN_GUIDE_ID), t.panel);
let mut open = None;
for skill in catalog::SKILLS {
ui.add_space(10.0);
ui.label(egui::RichText::new(skill.title).size(12.0).color(t.text_dim));
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
for tutorial in skill.tutorials {
if widgets::secondary_button(ui, tutorial.title).clicked() {
open = Some(tutorial.id);
}
}
});
}
if let Some(id) = open
&& let Err(e) = app.run("learn.show", json!({ "tutorial": id }))
{
app.status(e);
}
}
/// The Home screen's Recent Files (#663): the first of File › Open Recent Files, each its name and
/// folder; a click opens it. Nothing when there are none.
fn recent(app: &mut VectorcraftApp, ui: &mut Ui) {
+3 -1
View File
@@ -719,7 +719,8 @@ pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
/// After a dialog closes, and each frame: a Missing Fonts dialog that another dialog replaced waits
/// for its turn again, the search a Missing Fonts or Find Linked Files dialog started stops once
/// that dialog is gone, and with no dialog open the choice about a stopped plug-in that another
/// dialog replaced opens again, else the next document's Missing Fonts dialog (one dialog at a
/// dialog replaced opens again, else the questions about the next restored document's linked files
/// ([`crate::recovery::ask_next`]), else the next document's Missing Fonts dialog (one dialog at a
/// time).
pub(crate) fn settle(app: &mut VectorcraftApp) {
crate::placing::settle_swatch_paste(app);
@@ -727,6 +728,7 @@ pub(crate) fn settle(app: &mut VectorcraftApp) {
missing_fonts::stop_when_closed(app);
find_links::stop_when_closed(app);
safe_mode::reopen(app);
crate::recovery::ask_next(app);
missing_fonts::open_next(app);
}
+53 -9
View File
@@ -54,14 +54,7 @@ fn after_open_for(app: &mut VectorcraftApp, r: &Value, notes: Vec<String>, web:
let rows = |k: &str| r[k].as_array().cloned().unwrap_or_default();
let (missing, modified) = (rows("missingLinks"), ids(&rows("modifiedLinks")));
let notice = if missing.is_empty() || web {
let unreadable = (!missing.is_empty()).then(|| {
crate::i18n::msg_n(
missing.len() as u64,
"{n} linked file can't be read here: its preview shows",
"{n} linked files can't be read here: their previews show",
&[],
)
});
let unreadable = (!missing.is_empty()).then(|| unreadable_status(missing.len()));
ask_update(app, modified).or(unreadable)
} else {
open(app, missing, modified, &notes);
@@ -71,6 +64,40 @@ fn after_open_for(app: &mut VectorcraftApp, r: &Value, notes: Vec<String>, web:
super::settle(app);
}
/// The status bar's line for `n` linked files the web can't read.
fn unreadable_status(n: usize) -> String {
crate::i18n::msg_n(
n as u64,
"{n} linked file can't be read here: its preview shows",
"{n} linked files can't be read here: their previews show",
&[],
)
}
/// What the linked files of a document that opened (`document.open`'s result `r`) need, without
/// asking yet → the status bar's lines about them, the missing files to ask about and the ids of
/// the modified images to offer for update ([`ask_next`]). The web reads no linked files, so the
/// missing ones get a line there; without Update Links: Ask When Modified the modified ones get a
/// line instead of the offer.
pub(crate) fn questions(app: &VectorcraftApp, r: &Value) -> (Vec<String>, Vec<Value>, Vec<Value>) {
questions_for(app, r, cfg!(target_arch = "wasm32"))
}
fn questions_for(app: &VectorcraftApp, r: &Value, web: bool) -> (Vec<String>, Vec<Value>, Vec<Value>) {
let rows = |k: &str| r[k].as_array().cloned().unwrap_or_default();
let (mut missing, mut modified) = (rows("missingLinks"), ids(&rows("modifiedLinks")));
let mut lines = vec![];
if web && !missing.is_empty() {
lines.push(unreadable_status(missing.len()));
missing.clear();
}
if !modified.is_empty() && app.session.prefs.update_links != "askWhenModified" {
lines.push(changed_on_disk_status(modified.len()));
modified.clear();
}
(lines, missing, modified)
}
fn open(app: &mut VectorcraftApp, missing: Vec<Value>, modified: Vec<Value>, notes: &[String]) {
let fields = json!({ "missing": missing, "modified": modified, "notes": notes, "applyToAll": false, "path": "" });
app.ui.dialog = Some(Dialog::new(KIND, fields));
@@ -85,7 +112,7 @@ pub(super) fn notes(d: &Dialog) -> Vec<String> {
/// Ask about the missing files `rest`, one at a time, then offer the modified ones `modified` for
/// update. A line about the modified files is followed by the open's `notes`
/// ([`crate::io::show_opened`]).
pub(super) fn ask_next(app: &mut VectorcraftApp, rest: Vec<Value>, modified: Vec<Value>, notes: Vec<String>) {
pub(crate) fn ask_next(app: &mut VectorcraftApp, rest: Vec<Value>, modified: Vec<Value>, notes: Vec<String>) {
if rest.is_empty() {
app.ui.dialog = None;
if let Some(notice) = ask_update(app, modified) {
@@ -309,6 +336,23 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
/// A restored document's linked files: the web gets a line about the missing ones, and
/// without Update Links: Ask When Modified the modified ones get a line; the rest are asked
/// about.
#[test]
fn the_questions_about_linked_files_wait_or_become_lines() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let r = json!({ "missingLinks": [{"name": "a.png", "path": "/x/a.png", "ids": [1]}], "modifiedLinks": [{"ids": [2]}, {"ids": [3]}] });
app.session.prefs.update_links = "askWhenModified".into();
assert_eq!(questions_for(&app, &r, false), (vec![], r["missingLinks"].as_array().unwrap().clone(), vec![json!(2), json!(3)]));
let changed = "2 linked images changed on disk: Update Links shows the new versions".to_string();
app.session.prefs.update_links = "manually".into();
assert_eq!(questions_for(&app, &r, false), (vec![changed.clone()], r["missingLinks"].as_array().unwrap().clone(), vec![]));
let unreadable = "1 linked file can't be read here: its preview shows".to_string();
assert_eq!(questions_for(&app, &r, true), (vec![unreadable, changed], vec![], vec![]));
assert!(app.ui.dialog.is_none(), "nothing is asked yet");
}
/// The question about modified linked files uses the form the language's plural rule picks for
/// the count.
#[test]
+5 -5
View File
@@ -253,7 +253,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
// Version first; the legacy version takes only its own options.
let legacy = legacy_ai(d);
let mut rows: Vec<&FormatOption> = shown(f).filter(|o| LEGACY_ONLY.contains(&o.name) == legacy || o.name == "aiVersion").collect();
let mut rows: Vec<&FormatOption> = shown(f).filter(|o| fileio::file_reads(f, legacy, o.name)).collect();
// EPS's PostScript level comes last, as in Export's EPS Options.
rows.sort_by_key(|o| (o.name != "aiVersion", f.id == "eps" && o.name == "level"));
for o in rows {
@@ -269,10 +269,6 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// The `.ai` options only the legacy version takes (and the only ones it takes, besides the
/// version).
const LEGACY_ONLY: &[&str] = &["transparentImages", "hiddenObjects", "outlineText"];
/// Is a `.ai` file to be saved in the legacy version 7 (`aiVersion: "7"`)?
fn legacy_ai(d: &Dialog) -> bool {
d.str("format") == "ai" && d.str("aiVersion").trim() == "7"
@@ -615,9 +611,13 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
return Err("name the file".into());
}
let path = if d.bool("__pick") { with_extension(path.trim(), f) } else { path };
// Only the options the file reads: a `.ai` file's version takes its own (when the document's
// own file is a `.ai`, the save remembers the others as they were).
let legacy = legacy_ai(d);
let mut options: Map<String, Value> = f
.options
.iter()
.filter(|o| fileio::file_reads(f, legacy, o.name))
.filter_map(|o| d.fields.get(o.name).filter(|v| !v.is_null() && v.as_str() != Some("")).map(|v| (o.name.to_string(), v.clone())))
.collect();
if uncompressed(d) {
@@ -125,6 +125,33 @@ fn save_as_ai_version_7_writes_a_file_per_artboard() {
assert_eq!((st.save_options["hiddenObjects"].clone(), st.save_options["outlineText"].clone()), (json!("visible"), json!(true)));
}
/// Save Options sends a `.ai` save only the options its version reads, so a person's save gets no
/// note about an option the file doesn't read, and the other version's choices stay as the last
/// save left them.
#[test]
fn ai_save_options_send_only_the_options_the_version_reads() {
let (mut app, _, _) = desktop("art.ai");
for (round, legacy) in [true, false, true].into_iter().enumerate() {
assert_eq!(app.run("file.saveAs", json!({})).unwrap()["pending"], KIND);
frame(&mut app, false);
match round {
1 => assert!(dialog(&app).bool("outlineText"), "the legacy choice is shown as last saved"),
2 => assert!(!dialog(&app).bool("editingData"), "the PDF-compatible choice is shown as last saved"),
_ => {}
}
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("aiVersion".into(), json!(if legacy { "7" } else { "pdf" }));
let (key, value) = if legacy { ("outlineText", true) } else { ("editingData", false) };
d.fields.insert(key.into(), json!(value));
let d = d.clone();
app.ui.dialog = None;
let r = confirm(&mut app, &d).unwrap();
assert!(!r["warnings"].to_string().contains("was not used"), "round {round}: {r}");
}
let st = app.session.active().unwrap();
assert_eq!((st.save_options["outlineText"].clone(), st.save_options["editingData"].clone()), (json!(true), json!(false)));
}
/// An Illustrator file opened, then saved as `.ai`: the options dialog stays open through the
/// Enter that closed the save panel, and only an Enter after it is let go confirms it.
#[test]
@@ -88,6 +88,39 @@ fn opens_on_defaults_and_draws_every_view() {
assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard");
}
/// The dialog's own request, with a row of every format its Format menu offers and every format's
/// settings as it opens, gets no note about an option a file doesn't read.
#[test]
fn the_dialogs_own_rows_and_settings_get_no_unused_option_notes() {
let (mut app, _) = app(1, true);
open(&mut app);
let rows: Vec<Value> = [
json!({"format": "png"}),
json!({"format": "png8"}),
json!({"format": "jpg", "quality": 100}),
json!({"format": "webp"}),
json!({"format": "gif"}),
json!({"format": "tiff"}),
json!({"format": "svg"}),
json!({"format": "pdf"}),
json!({"format": "pdf", "marks": {"trim": true}, "suffix": "-print"}),
json!({"format": "eps"}),
json!({"format": "ai"}),
json!({"format": "vectorcraft"}),
]
.into_iter()
.map(|mut r| {
r["scale"] = json!("1x");
r
})
.collect();
set(&mut app, "formats", json!(rows));
let p = export_for_screens::params(&app, app.ui.dialog.as_ref().unwrap()).unwrap();
let r = app.session.execute("document.exportForScreens", &p).unwrap();
let unused: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).collect();
assert!(unused.is_empty(), "{unused:?}");
}
#[test]
fn checked_artboards_read_as_a_range() {
let (mut app, _) = app(5, false);
+6 -2
View File
@@ -1691,7 +1691,7 @@ pub(crate) fn end_stale_preview(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
/// What the font menus preview on and apply to: the Type tool's selected range while it edits
/// text (the whole text when nothing is selected), else the selected text objects.
/// text (the whole text when nothing is selected), else every frame of each selected story.
pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPick) {
let previewing = crate::panels::pstate::<bool>(ctx, PREVIEWING);
match pick {
@@ -1791,7 +1791,11 @@ fn font_command(app: &VectorcraftApp, family: &str, style: Option<&str>) -> (&'s
}
("text.setRangeStyle", p)
}
None => ("text.setStyle", p),
None => {
// With the Selection tool: every frame of each selected story.
crate::panels::character::target_selected_stories(app, &mut p);
("text.setStyle", p)
}
}
}
+7 -4
View File
@@ -4702,11 +4702,11 @@ axis Both И двете
@msg unknown effect `{id}` непознат ефект `{id}`
@msg unknown format непознат формат
@msg unknown format `{f}` (Save writes {_1}) непознат формат `{f}` (Запазването записва {_1})
@msg unknown freeform mode `{m}` (points, lines) непознат режим на свободна форма `{m}` (точки, линии)
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) непознат вид градиент `{k}` (линеен, радиален, конусен, ромбовиден, свободна форма)
@msg unknown freeform mode `{m}` (points, lines) непознат режим на свободна форма `{m}` (points, lines)
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) непознат вид градиент `{k}` (linear, radial, conic, diamond, freeform)
@msg unknown highlight `{h}` непознат акцент `{h}`
@msg unknown option `{path}.{k}` непозната опция `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) непознати надписи `{o}` (запазване, симулиране или отхвърляне)
@msg unknown overprints `{o}` (preserve, simulate or discard) непознати надписи `{o}` (preserve, simulate или discard)
@msg unknown panel `{raw}` непознат панел `{raw}`
@msg unknown preference `{key}` непозната настройка `{key}`
@msg Preferences imported. Настройките са импортирани.
@@ -4724,7 +4724,7 @@ axis Both И двете
@msg `{key}` must be an object `{key}` трябва да бъде обект
@msg `prefs.import` runs on its own, not as a batch step `prefs.import` се изпълнява самостоятелно, а не като стъпка от пакет
@msg unknown preset (one of {_1}) непозната предварителна настройка (една от {_1})
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) непозната предварителна настройка `{name}` (висока, средна, ниска или запазена: вижте flattener.presets.list)
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) непозната предварителна настройка `{name}` (high, medium, low или запазена: вижте flattener.presets.list)
@msg unknown preset `{name}` (see flattener.presets.list) непозната предварителна настройка `{name}` (вижте flattener.presets.list)
@msg unknown preset `{name}` (see webExport.presets.list) непозната предварителна настройка `{name}` (вижте webExport.presets.list)
@msg unknown side `{other}` (left|right|top|bottom) непозната страна `{other}` (left|right|top|bottom)
@@ -5027,6 +5027,7 @@ effect Soft Меко
VectorCraft Title Bar on Every Desktop Заглавна лента на VectorCraft на всеки работен плот
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Докато е зададена, променливата на средата VECTORCRAFT_TITLEBAR избира заглавната лента.
System Title Bar takes precedence over this option while it is on. Докато е включена, „Заглавна лента на системата“ има предимство пред тази опция.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Докато „Заглавна лента на VectorCraft на всеки работен плот“ е изключена, този работен плот изчертава заглавната лента на прозореца независимо дали тази опция е включена.
Value Axis Ос на стойностите
On Left Side От лявата страна
On Right Side От дясната страна
@@ -6057,6 +6058,8 @@ axis Y Axis Y ос
Action Options… Опции на действието…
Action Options Опции на действието
Playback Options… Опции за възпроизвеждане…
Button Mode Режим с бутони
No actions. Няма действия.
Insert Stop… Вмъкване на спиране…
Insert Stop Вмъкване на спиране
Record Stop Записване на спиране
Can't render this file because it contains an unexpected character in line 2 and column 73.

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