mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
Merge remote-tracking branch 'upstream/main' into fix-7741-pdf-text-stroke-cap
# Conflicts: # docs/roadmap.md # docs/target-app-parity.md
This commit is contained in:
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-15 · **Change:** minor (G5: built-in Neon & Glow graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); G5 estimate unchanged (3–13 h); previously: Tech Circuit brush library, G5; previously: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -225,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-15 | minor | Graphic Styles: built-in Neon & Glow graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); G5 estimate unchanged (3–13 h). |
|
||||
| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. |
|
||||
| 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. |
|
||||
| 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. |
|
||||
|
||||
@@ -358,6 +358,34 @@ mod tests {
|
||||
assert!((p.x - 25.0).abs() < 1e-6 && (p.y - 25.0).abs() < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coons_places_are_where_the_patch_bends() {
|
||||
// A rectangle's sides have 65 evenly spaced points.
|
||||
let [u, v] = Coons::rect(Rect::new(0.0, 0.0, 100.0, 50.0)).places();
|
||||
assert_eq!((u.len(), v.len()), (65, 65));
|
||||
assert!(u.iter().enumerate().all(|(i, x)| (x - i as f64 / 64.0).abs() < 1e-12), "{u:?}");
|
||||
// A top object with a curved top: between two places next to each other the patch is
|
||||
// straight, in `u` and in `v`.
|
||||
let mut path = kurbo::BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.curve_to((30.0, -20.0), (70.0, 10.0), (100.0, 0.0));
|
||||
path.line_to((100.0, 50.0));
|
||||
path.line_to((40.0, 50.0));
|
||||
path.line_to((0.0, 50.0));
|
||||
path.close_path();
|
||||
let c = Coons::from_top_object(&PathData::from_bezpath(&path), Some([0, 1, 2, 4])).unwrap();
|
||||
let [u, v] = c.places();
|
||||
assert!(u.first() == Some(&0.0) && u.last() == Some(&1.0) && u.windows(2).all(|w| w[0] < w[1]), "{u:?}");
|
||||
assert!(v.first() == Some(&0.0) && v.last() == Some(&1.0) && v.windows(2).all(|w| w[0] < w[1]), "{v:?}");
|
||||
for w in u.windows(2) {
|
||||
for y in [0.0, 0.3, 1.0] {
|
||||
let mid = c.eval((w[0] + w[1]) / 2.0, y);
|
||||
let half = c.eval(w[0], y).midpoint(c.eval(w[1], y));
|
||||
assert!(mid.distance(half) < 1e-9, "{w:?} {y}: {mid:?} {half:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_go_on_past_the_content_box() {
|
||||
// A stroke outline reaches past the box: a flat mesh and a rectangle stay the identity there.
|
||||
|
||||
@@ -338,6 +338,31 @@ impl Coons {
|
||||
let end = |side: &[Point]| side.last().copied().unwrap_or_default();
|
||||
[self.top[0], end(&self.top), end(&self.bottom), self.bottom[0]]
|
||||
}
|
||||
|
||||
/// The places in `u` where the top or bottom side has a point, and the places in `v` where the
|
||||
/// left or right side has one, rising from 0 to 1. Between two places next to each other, each
|
||||
/// side runs straight, so [`Self::eval`] changes linearly in `u` and in `v` inside each cell of
|
||||
/// the grid they make.
|
||||
pub fn places(&self) -> [Vec<f64>; 2] {
|
||||
let sides = [&self.top, &self.right, &self.bottom, &self.left];
|
||||
let at = |which: usize| -> Vec<f64> {
|
||||
match self.knots.as_deref().and_then(|k| k.get(which)) {
|
||||
Some(knots) => knots.clone(),
|
||||
None => {
|
||||
let last = sides[which].len().saturating_sub(1).max(1);
|
||||
(0..=last).map(|i| i as f64 / last as f64).collect()
|
||||
}
|
||||
}
|
||||
};
|
||||
let merge = |a: usize, b: usize| {
|
||||
let mut v = at(a);
|
||||
v.extend(at(b));
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
[merge(0, 2), merge(1, 3)]
|
||||
}
|
||||
}
|
||||
|
||||
/// The most points one side of [`Coons::from_top_object`] holds while it adds points between its
|
||||
|
||||
@@ -162,6 +162,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[
|
||||
BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency },
|
||||
BuiltinStyleLibrary { id: "game-hud", name: "Game HUD", make: game_hud },
|
||||
BuiltinStyleLibrary { id: "status-effects", name: "Status Effects", make: status_effects },
|
||||
BuiltinStyleLibrary { id: "neon-glow", name: "Neon & Glow", make: neon_glow },
|
||||
];
|
||||
|
||||
/// Built-in library `id`, made once.
|
||||
@@ -619,6 +620,44 @@ fn status_effects() -> Vec<GraphicStyle> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Neon & Glow: original multi-layer procedural styles with neon tubes, laser beams, and glowing halos.
|
||||
fn neon_glow() -> Vec<GraphicStyle> {
|
||||
vec![
|
||||
style(
|
||||
"Cyan Neon",
|
||||
vec![S(StrokeLayer { opacity: 0.7, ..round("#00e5ff", 6.0) }), S(round("#ffffff", 1.5))],
|
||||
vec![fx("stylize.dropShadow", json!({"mode": "screen", "opacity": 85.0, "x": 0.0, "y": 0.0, "blur": 8.0, "color": "#00e5ff"}))],
|
||||
),
|
||||
GraphicStyle {
|
||||
blend: BlendMode::Screen,
|
||||
..style(
|
||||
"Magenta Laser",
|
||||
vec![S(StrokeLayer { blend: BlendMode::Screen, ..round("#ff007f", 5.0) }), S(round("#ffffff", 1.5))],
|
||||
vec![fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 90.0, "blur": 8.0, "color": "#ff007f"}))],
|
||||
)
|
||||
},
|
||||
style(
|
||||
"Amber Tube",
|
||||
vec![S(StrokeLayer { opacity: 0.8, ..round("#ff8c00", 6.0) }), S(round("#ffea00", 1.5))],
|
||||
vec![fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 85.0, "blur": 8.0, "color": "#ff8c00"}))],
|
||||
),
|
||||
style(
|
||||
"Electric Lime",
|
||||
vec![S(StrokeLayer { opacity: 0.75, ..round("#39ff14", 6.0) }), S(round("#ffffff", 1.5))],
|
||||
vec![fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 90.0, "blur": 8.0, "color": "#39ff14"}))],
|
||||
),
|
||||
style(
|
||||
"Pulse Violet",
|
||||
vec![S(StrokeLayer { opacity: 0.75, ..round("#7b2cbf", 5.0) }), S(round("#e0aaff", 1.5))],
|
||||
vec![
|
||||
fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 75.0, "blur": 6.0, "color": "#c77dff", "source": "edge"})),
|
||||
fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 85.0, "blur": 8.0, "color": "#7b2cbf"})),
|
||||
],
|
||||
),
|
||||
style("Cobalt Phosphor", vec![S(round("#0047ab", 6.0)), S(round("#ffffff", 1.5))], vec![fx("blur.gaussian", json!({"radius": 2.0}))]),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -750,4 +789,36 @@ mod tests {
|
||||
let Paint::Gradient(gp) = g.appearance.fill_paint() else { panic!("a gradient") };
|
||||
assert!(gp.swatch.is_none() && gp.gradient.stops.iter().all(|s| s.swatch.is_none() && s.tint == 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neon_and_glow_styles_are_defined_with_multi_strokes_and_effects() {
|
||||
let lib = builtin_style_library("neon-glow").expect("neon-glow library exists");
|
||||
assert_eq!(lib.name, "Neon & Glow");
|
||||
assert_eq!(lib.len(), 6);
|
||||
let names: Vec<&str> = lib.styles.iter().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(names, ["Cyan Neon", "Magenta Laser", "Amber Tube", "Electric Lime", "Pulse Violet", "Cobalt Phosphor"]);
|
||||
for g in &lib.styles {
|
||||
let strokes = g.appearance.items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count();
|
||||
assert!(strokes >= 2, "{} has {} strokes, expected >= 2", g.name, strokes);
|
||||
assert!(!g.appearance.effects.is_empty(), "{} has effects", g.name);
|
||||
}
|
||||
let cyan = lib.style("Cyan Neon").unwrap();
|
||||
assert_eq!(cyan.appearance.effects[0].id, "stylize.dropShadow");
|
||||
let magenta = lib.style("Magenta Laser").unwrap();
|
||||
assert_eq!(magenta.blend, BlendMode::Screen);
|
||||
let pulse = lib.style("Pulse Violet").unwrap();
|
||||
let pulse_fx: Vec<&str> = pulse.appearance.effects.iter().map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(pulse_fx, ["stylize.innerGlow", "stylize.outerGlow"]);
|
||||
let cobalt = lib.style("Cobalt Phosphor").unwrap();
|
||||
assert_eq!(cobalt.appearance.effects[0].id, "blur.gaussian");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neon_glow_round_trips_through_vcstyles() {
|
||||
let lib = builtin_style_library("neon-glow").unwrap();
|
||||
let text = write(&lib);
|
||||
assert!(sniff(&text));
|
||||
let read_back = read(&text, "Fallback").unwrap();
|
||||
assert_eq!(*lib, read_back);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2};
|
||||
|
||||
use crate::distort::{transform_move, transform_offsets};
|
||||
use crate::group::{has_own_paint, paints};
|
||||
use crate::reshape::{as_art, clipping_text_outline, dressed, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
|
||||
use crate::reshape::{
|
||||
as_art, clipping_text_outline, dressed, geometry_effects, glyph_outlines, outline_nested_text, paint_type_clip, reshape_leaves,
|
||||
};
|
||||
use crate::{
|
||||
GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
|
||||
has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape,
|
||||
@@ -689,6 +691,9 @@ fn moved_type_group(n: &Node) -> Option<Node> {
|
||||
/// them. `None` when no type stays type.
|
||||
fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
|
||||
fn keep(d: &Document, n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) {
|
||||
if !clipping {
|
||||
paint_type_clip(Some(d), n);
|
||||
}
|
||||
let clips = n.clips();
|
||||
let Some(ch) = n.children_mut() else { return };
|
||||
for (i, c) in ch.iter_mut().enumerate() {
|
||||
|
||||
@@ -182,29 +182,29 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
REVOLVE,
|
||||
"Revolve…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance; surfaces with graphics keep every face), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the swept side, see threeD.mapGraphic: symbol: name of a document symbol, face: front (the side), x: pt (0; the offset of the graphic's center from the middle of the side, around the axis: the angle times the profile's distance from the axis at that point), y: pt (0; along the profile, toward its lower end), scale: % (1..10000, 100), rotation: degrees (-360..360, 0; counterclockwise); wrapped around the side at its own size, so it reaches farther around where the profile comes nearer the axis, shaded with it and drawn only where the side's outside faces the viewer; a full turn cuts it at the back; nothing on the caps)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true,"graphics":[]}),
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance; surfaces with graphics keep every face), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the swept side, see threeD.mapGraphic: symbol: name of a document symbol, face: front (the side), x: pt (0; the offset of the graphic's center from the middle of the side, around the axis: the angle times the profile's distance from the axis at that point), y: pt (0; along the profile, toward its lower end), scale: % (1..10000, 100), rotation: degrees (-360..360, 0; counterclockwise); wrapped around the side at its own size, so it reaches farther around where the profile comes nearer the axis, shaded with it and drawn only where the side's outside faces the viewer; a full turn cuts it at the back; nothing on the caps), render: object ({mode: shaded}; mode: shaded|wireframe, wireframe draws the edges of the faces the effect draws, each once, stroked 0.5 pt black with round joins, with no fill, shadow or graphics, whatever the opacity of the fills, strokes and group members, while the effect stays live; the stroke is VectorCraft's own)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true,"graphics":[],"render":{"mode":"shaded"}}),
|
||||
),
|
||||
g(
|
||||
EXTRUDE,
|
||||
"Extrude & Bevel…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the caps, see threeD.mapGraphic: symbol: name of a document symbol, face: front|back (front; the back reads left to right as seen from behind), x: pt (0; the offset of the graphic's center from the center of the object's bounds, in the face's own plane), y: pt (0), scale: % (1..10000, 100; of the symbol's own size), rotation: degrees (-360..360, 0; counterclockwise); drawn turned, foreshortened and shaded with the cap and clipped to it, on a solid cap only)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[]}),
|
||||
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the caps, see threeD.mapGraphic: symbol: name of a document symbol, face: front|back (front; the back reads left to right as seen from behind), x: pt (0; the offset of the graphic's center from the center of the object's bounds, in the face's own plane), y: pt (0), scale: % (1..10000, 100; of the symbol's own size), rotation: degrees (-360..360, 0; counterclockwise); drawn turned, foreshortened and shaded with the cap and clipped to it, on a solid cap only), render: object ({mode: shaded}; mode: shaded|wireframe, as Revolve's)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[],"render":{"mode":"shaded"}}),
|
||||
),
|
||||
g(
|
||||
INFLATE,
|
||||
"Inflate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}),
|
||||
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), render: object ({mode: shaded}; mode: shaded|wireframe, as Revolve's)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"render":{"mode":"shaded"}}),
|
||||
),
|
||||
g(
|
||||
ROTATE,
|
||||
"Rotate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the plane, as Extrude & Bevel's, with face front only)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
|
||||
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[]}),
|
||||
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the plane, as Extrude & Bevel's, with face front only), render: object ({mode: shaded}; mode: shaded|wireframe, as Revolve's)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
|
||||
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[],"render":{"mode":"shaded"}}),
|
||||
),
|
||||
g(
|
||||
CLASSIC_EXTRUDE,
|
||||
|
||||
@@ -91,12 +91,13 @@ pub use revolve::{
|
||||
CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in,
|
||||
};
|
||||
pub use solids::{
|
||||
CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, GRAPHICS, GraphicFrame, INFLATE, MAX_GRAPHICS, MappedGraphic, ROTATE, ROTATE_SURFACES,
|
||||
ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, graphic_bounds,
|
||||
graphic_frame, graphic_symbols, has_solid, inflate_options, is_classic_three_d, is_three_d, mapped_graphics, missing_symbol,
|
||||
names_graphic_symbol, rename_graphic_symbol, rename_graphic_symbol_in, solid_art, solid_art_in, takes_graphics, three_d_meshes,
|
||||
three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, validate_solid_in,
|
||||
validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, with_graphic,
|
||||
CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, GRAPHICS, GraphicFrame, INFLATE, MAX_GRAPHICS, MappedGraphic, RENDER, RENDER_MODES, ROTATE,
|
||||
ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options,
|
||||
graphic_bounds, graphic_frame, graphic_symbols, has_solid, inflate_options, is_classic_three_d, is_three_d, mapped_graphics, missing_symbol,
|
||||
names_graphic_symbol, rename_graphic_symbol, rename_graphic_symbol_in, renders_wireframe, solid_art, solid_art_in, takes_graphics,
|
||||
three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid,
|
||||
validate_solid_in, validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options,
|
||||
with_graphic,
|
||||
};
|
||||
pub use solids::{
|
||||
GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, GRAPHICS_NOT_TAKEN, appearance_graphic_symbols, graphic_frame_at, graphics_reshaped_after,
|
||||
|
||||
@@ -746,9 +746,13 @@ pub(crate) fn clipping_text_outline(t: &Node) -> Option<Node> {
|
||||
|
||||
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
|
||||
/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes
|
||||
/// its outlines with its visibility and effects ([`clipping_text_outline`]). The swatches and
|
||||
/// patterns that live-effect plug-ins painting their pieces name are looked up in `doc`.
|
||||
/// its outlines with its visibility and effects ([`clipping_text_outline`]), and its fills and
|
||||
/// strokes become art of their own ([`paint_type_clip`]). The swatches and patterns that
|
||||
/// live-effect plug-ins painting their pieces name are looked up in `doc`.
|
||||
pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) {
|
||||
if !clipping {
|
||||
paint_type_clip(doc, n);
|
||||
}
|
||||
let clips = n.clips();
|
||||
let Some(ch) = n.children_mut() else { return };
|
||||
for (i, c) in ch.iter_mut().enumerate() {
|
||||
@@ -766,6 +770,36 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping
|
||||
}
|
||||
}
|
||||
|
||||
/// When `n` is a clip group or clipping layer whose clipping path is type that paints
|
||||
/// ([`Node::clip_paint`]), make it the art the canvas draws for it: the type's outlines clip
|
||||
/// ([`clipping_text_outline`]), its fills as art ([`as_art`]) paint behind the clipped art inside
|
||||
/// the clip, and its strokes as art paint over it, outside the clip, in a group that `n` becomes
|
||||
/// (keeping its id, name and transparency) with the clip group inside. The other members stay as
|
||||
/// they are. Hidden type paints nothing.
|
||||
pub(crate) fn paint_type_clip(doc: Option<&Document>, n: &mut Node) {
|
||||
let Some((first, rest)) = n.children().and_then(|ch| ch.split_first()) else { return };
|
||||
if !n.clips() || !matches!(first.kind, NodeKind::Text(_)) || !first.visible {
|
||||
return;
|
||||
}
|
||||
let paint = first.clip_paint();
|
||||
if paint.fill.is_none() && paint.stroke.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(clip) = clipping_text_outline(first) else { return };
|
||||
let art = |part: Option<Node>| part.and_then(|p| as_art(doc, &p, None)).map(Arc::new);
|
||||
let members: Vec<Arc<Node>> = std::iter::once(Arc::new(clip)).chain(art(paint.fill)).chain(rest.iter().cloned()).collect();
|
||||
let stroke = art(paint.stroke);
|
||||
let id = n.id;
|
||||
match (&mut n.kind, stroke) {
|
||||
(NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }, None) => *children = members,
|
||||
(NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. }, Some(stroke)) => {
|
||||
*children = vec![Arc::new(Node::new(id, NodeKind::Group { children: members, clip: true })), stroke];
|
||||
*clip = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied:
|
||||
/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped
|
||||
/// frame. Type's own fills and strokes keep their effects on its whole outline. An image's own
|
||||
|
||||
@@ -8,7 +8,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, N
|
||||
use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2};
|
||||
use vectorcraft_three_d::{Edge, Revolve, SolidFaces};
|
||||
|
||||
use crate::solids::wireframe::WireFace;
|
||||
use crate::solids::wireframe::{WireFace, wireframe_params};
|
||||
use crate::solids::{
|
||||
Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds,
|
||||
};
|
||||
@@ -154,7 +154,12 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value)
|
||||
let b = union_bounds(&shapes).ok_or(EMPTY)?;
|
||||
let options = shared_options(&shapes, options_of(id, p))?;
|
||||
let mut copies = 0;
|
||||
shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?;
|
||||
// In wireframe mode the edges are drawn with no paint copies, graphics or shadow.
|
||||
let wire = wireframe_params(id, p);
|
||||
shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items).filter(|_| !wire), &mut copies))?;
|
||||
if wire {
|
||||
return Ok(());
|
||||
}
|
||||
// Without graphics, or without the document they draw from, no member is measured for them.
|
||||
if let Some(doc) = doc.filter(|_| id == REVOLVE && !crate::solids::mapped_graphics(p).is_empty()) {
|
||||
match graphics_member(&shapes, b, options).and_then(|k| shapes.get(k)) {
|
||||
@@ -176,7 +181,11 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value)
|
||||
let path = geometry(&path, before, &GeomContext::of(n));
|
||||
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
|
||||
let paints = solid_paints(n);
|
||||
check_surface(&path, b, options_of(id, p), surface_item(&paints), &mut 0)?;
|
||||
let wire = wireframe_params(id, p);
|
||||
check_surface(&path, b, options_of(id, p), surface_item(&paints).filter(|_| !wire), &mut 0)?;
|
||||
if wire {
|
||||
return Ok(());
|
||||
}
|
||||
check_graphics(doc, id, &path, b, options_of(id, p), p, &mut 0)?;
|
||||
check_shadow(p, [&path], b, options_of(id, p))
|
||||
}
|
||||
@@ -261,7 +270,11 @@ pub(crate) fn validate_item_of(doc: Option<&Document>, n: &Node, id: &str, p: &V
|
||||
let options = options_of(kind, ¶ms);
|
||||
let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item));
|
||||
let b = path.bounds().ok_or(EMPTY)?;
|
||||
check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?;
|
||||
let wire = wireframe_params(kind, ¶ms);
|
||||
check_surface(&path, b, options, (crate::paints(item) && !wire).then_some(item), &mut copies)?;
|
||||
if wire {
|
||||
continue;
|
||||
}
|
||||
// The graphics of every item add their paints' copies to the graphics' budget, as they
|
||||
// are drawn; only the edited item's graphics are refused here.
|
||||
let graphics = check_graphics(doc, kind, &path, b, options, ¶ms, &mut graphic_copies);
|
||||
@@ -324,6 +337,7 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> {
|
||||
pub(crate) fn validate_params_of(id: &str, p: &Value) -> Result<(), String> {
|
||||
validate_revolve_params(p)?;
|
||||
crate::solids::graphics::validate(id, p)?;
|
||||
crate::solids::wireframe::validate_render(id, p)?;
|
||||
if id == CLASSIC_REVOLVE {
|
||||
classic::validate(p)?;
|
||||
}
|
||||
@@ -728,14 +742,7 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul
|
||||
/// or stroke's Revolve are looked up in `doc`. Each face carries the width of the stroke the canvas
|
||||
/// draws around it ([`surface`]).
|
||||
pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<crate::solids::wireframe::FacesAndShadow, String> {
|
||||
let faces = |path: &PathData, b: Rect, fx: &Effect, options: Revolve| -> Result<Vec<WireFace>, String> {
|
||||
if fx.id == CLASSIC_REVOLVE {
|
||||
let p = merged_params(&fx.id, &fx.params);
|
||||
let facets = vectorcraft_three_d::revolve_facets(path, b, options)?;
|
||||
return Ok(facets.iter().map(|f| WireFace { path: f.path(), stroke: crate::solids::classic::stroke_width(f, &p) }).collect());
|
||||
}
|
||||
Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(|f| WireFace { path: f.path(), stroke: SEAM_WIDTH }).collect())
|
||||
};
|
||||
let faces = wire_faces;
|
||||
if is_container(n) {
|
||||
let index = n
|
||||
.appearance
|
||||
@@ -752,7 +759,9 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<cra
|
||||
for s in shapes.iter().filter(|s| surface_item(&s.items).is_some()) {
|
||||
out.extend(faces(&s.path, b, fx, options)?);
|
||||
}
|
||||
return Ok((out, group_shadow(n.id, fx, &shapes, b, options, vec![])?));
|
||||
// In wireframe mode the canvas draws no shadow.
|
||||
let shadow = if crate::solids::renders_wireframe(fx) { vec![] } else { group_shadow(n.id, fx, &shapes, b, options, vec![])? };
|
||||
return Ok((out, shadow));
|
||||
}
|
||||
let (path, _) = object_profile(n, "Revolve")?;
|
||||
let ctx = GeomContext::of(n);
|
||||
@@ -763,7 +772,7 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<cra
|
||||
let item = surface_item(&paints).ok_or("the revolved object has no fill or stroke")?;
|
||||
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
|
||||
let surface = faces(&path, b, fx, fx_options(fx))?;
|
||||
let shadow = shadow_of(n.id, fx, &path, item, b, fx_options(fx), None)?;
|
||||
let shadow = if crate::solids::renders_wireframe(fx) { None } else { shadow_of(n.id, fx, &path, item, b, fx_options(fx), None)? };
|
||||
return Ok((surface, shadow.into_iter().map(Arc::new).collect()));
|
||||
}
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
@@ -781,6 +790,28 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<cra
|
||||
Ok((out, vec![]))
|
||||
}
|
||||
|
||||
/// The faces Revolve or Revolve (Classic) `fx` sweeps from `path` about the bounds `b` with
|
||||
/// `options`, each with the width of the stroke the canvas draws around it.
|
||||
fn wire_faces(path: &PathData, b: Rect, fx: &Effect, options: Revolve) -> Result<Vec<WireFace>, String> {
|
||||
if fx.id == CLASSIC_REVOLVE {
|
||||
let p = merged_params(&fx.id, &fx.params);
|
||||
let facets = vectorcraft_three_d::revolve_facets(path, b, options)?;
|
||||
return Ok(facets.iter().map(|f| WireFace { path: f.path(), stroke: crate::solids::classic::stroke_width(f, &p) }).collect());
|
||||
}
|
||||
Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(|f| WireFace { path: f.path(), stroke: SEAM_WIDTH }).collect())
|
||||
}
|
||||
|
||||
/// The art Revolve `fx` in wireframe mode draws for object `id` from the profiles `paths`, all swept
|
||||
/// about the bounds `b` with `options`: the edges of their faces ([`crate::solids::wireframe::wire_art`]).
|
||||
/// `None` when a profile can't be swept or past the edge limit, when the canvas draws the source art.
|
||||
fn wire_art<'a>(id: NodeId, paths: impl IntoIterator<Item = &'a PathData>, b: Rect, fx: &Effect, options: Revolve) -> Option<Arc<Node>> {
|
||||
let mut faces = vec![];
|
||||
for path in paths {
|
||||
faces.extend(wire_faces(path, b, fx, options).ok()?.into_iter().map(|f| f.path));
|
||||
}
|
||||
crate::solids::wireframe::wire_art(id, &faces)
|
||||
}
|
||||
|
||||
/// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis.
|
||||
fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<crate::SolidMesh>, String> {
|
||||
let index = n
|
||||
@@ -824,13 +855,18 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
|
||||
let paints = solid_paints(n);
|
||||
let item = surface_item(&paints)?;
|
||||
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
|
||||
let (surface, kept) = object_surface(doc, n, &path, fx, item, expand, &mut copies)?;
|
||||
if let Some(b) = path.bounds()
|
||||
&& let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx), kept).ok()?
|
||||
{
|
||||
children.push(Arc::new(shadow));
|
||||
if crate::solids::renders_wireframe(fx) {
|
||||
// In wireframe mode the edges of the faces, with no shadow.
|
||||
children.push(wire_art(n.id, [&path], path.bounds()?, fx, fx_options(fx))?);
|
||||
} else {
|
||||
let (surface, kept) = object_surface(doc, n, &path, fx, item, expand, &mut copies)?;
|
||||
if let Some(b) = path.bounds()
|
||||
&& let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx), kept).ok()?
|
||||
{
|
||||
children.push(Arc::new(shadow));
|
||||
}
|
||||
children.push(Arc::new(surface));
|
||||
}
|
||||
children.push(Arc::new(surface));
|
||||
// Later geometry acts on the projected art as a whole through container evaluation.
|
||||
remaining = past_effect(&n.appearance.effects, index);
|
||||
remaining.retain(|e| !is_revolve(&e.id));
|
||||
@@ -850,6 +886,13 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
|
||||
let fx = item.effects().get(index)?;
|
||||
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(original));
|
||||
let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
|
||||
if crate::solids::renders_wireframe(fx) {
|
||||
let mut art = wire_art(n.id, [&p], p.bounds()?, fx, fx_options(fx))?;
|
||||
Arc::make_mut(&mut art).appearance.effects =
|
||||
item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect();
|
||||
children.push(art);
|
||||
continue;
|
||||
}
|
||||
let (mut art, kept) = object_surface(doc, n, &p, fx, item, expand, &mut copies)?;
|
||||
art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect();
|
||||
if let Some(b) = p.bounds()
|
||||
@@ -999,6 +1042,14 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
|
||||
reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes);
|
||||
let b = union_bounds(&shapes)?;
|
||||
let options = shared_options(&shapes, fx_options(fx)).ok()?;
|
||||
if crate::solids::renders_wireframe(fx) {
|
||||
// In wireframe mode the edges of every member's faces, with no shadow.
|
||||
let paths = shapes.iter().filter(|s| surface_item(&s.items).is_some()).map(|s| &s.path);
|
||||
let mut art = n.clone();
|
||||
art.kind = NodeKind::Group { children: vec![wire_art(n.id, paths, b, fx, options)?], clip: false };
|
||||
art.appearance = Appearance { effects: past_effect(&n.appearance.effects, index), ..Default::default() }.into();
|
||||
return Some(art);
|
||||
}
|
||||
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
|
||||
let mut copies = Copies::default();
|
||||
let mut surfaces = vec![];
|
||||
|
||||
@@ -36,7 +36,7 @@ pub use graphics::{
|
||||
pub(crate) mod shadow;
|
||||
|
||||
pub(crate) mod wireframe;
|
||||
pub use wireframe::three_d_wireframe_in;
|
||||
pub use wireframe::{RENDER, RENDER_MODES, renders_wireframe, three_d_wireframe_in};
|
||||
|
||||
pub const EXTRUDE: &str = "threeD.extrude";
|
||||
pub const INFLATE: &str = "threeD.inflate";
|
||||
@@ -274,6 +274,7 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> {
|
||||
crate::lighting::validate(p)?;
|
||||
shadow::validate(p)?;
|
||||
graphics::validate(id, p)?;
|
||||
wireframe::validate_render(id, p)?;
|
||||
if let Some(v) = p.get("bevel")
|
||||
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
|
||||
{
|
||||
@@ -324,7 +325,7 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at:
|
||||
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
|
||||
check_profiles(id, p, &shapes, b)?;
|
||||
check_solids(id, p, &shapes)?;
|
||||
return cast_shadow(id, p, &shapes).map(|_| ());
|
||||
return check_cast(id, p, &shapes);
|
||||
}
|
||||
let (path, rule) = object_profile(n, label)?;
|
||||
let anchors = || format!("{label} requires a shape with at least two anchors");
|
||||
@@ -342,7 +343,16 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at:
|
||||
let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }];
|
||||
check_profiles(id, p, &shapes, b)?;
|
||||
check_solids(id, p, &shapes)?;
|
||||
cast_shadow(id, p, &shapes).map(|_| ())
|
||||
check_cast(id, p, &shapes)
|
||||
}
|
||||
|
||||
/// With the shadow of 3D effect `id` with parameters `p` on, an error when the shadow of `shapes`
|
||||
/// is past the limits ([`cast_shadow`]). In wireframe mode no shadow is drawn, and none is checked.
|
||||
fn check_cast(id: &str, p: &Value, shapes: &[Profile]) -> Result<(), String> {
|
||||
if wireframe::wireframe_params(id, p) {
|
||||
return Ok(());
|
||||
}
|
||||
cast_shadow(id, p, shapes).map(|_| ())
|
||||
}
|
||||
|
||||
/// Do the solids 3D effect `id` with parameters `p` builds from the profiles of `shapes` about `b`
|
||||
@@ -1420,6 +1430,10 @@ type ShadowAndSurfaces = (Vec<Arc<Node>>, Vec<Arc<Node>>);
|
||||
/// off), cast from the faces the solids drawn kept ([`cast_built`]), to be drawn behind every
|
||||
/// surface of the object, and the surfaces.
|
||||
fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<ShadowAndSurfaces> {
|
||||
// In wireframe mode the effect draws the edges of the faces it builds, with no shadow.
|
||||
if wireframe::renders_wireframe(fx) {
|
||||
return Some((vec![], wireframe::wire_surfaces(n.id, fx, shapes)?));
|
||||
}
|
||||
let p = crate::merged_params(&fx.id, &fx.params);
|
||||
let cast = !is_classic_three_d(&fx.id) && shadow::options(&p).is_some();
|
||||
let (art, built) = surfaces_for(doc, n, fx, shapes, cast)?;
|
||||
@@ -1434,8 +1448,11 @@ fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) ->
|
||||
}
|
||||
|
||||
/// The shadow [`art_for`] draws for effect `fx` on `shapes` behind the surfaces of object `id`:
|
||||
/// none with the shadow off, and an error when it is past the limits.
|
||||
/// none with the shadow off or in wireframe mode, and an error when it is past the limits.
|
||||
pub(crate) fn shadow_for(id: NodeId, fx: &Effect, shapes: &[Profile]) -> Result<Vec<Arc<Node>>, String> {
|
||||
if wireframe::renders_wireframe(fx) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Ok(match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes)? {
|
||||
Some((caster, settings, cast)) => shadow::scoped_art(id, &caster, settings, cast, has_masks(shapes)),
|
||||
None => vec![],
|
||||
|
||||
@@ -1,22 +1,91 @@
|
||||
//! Expand as Wireframes: the edges of the faces a 3D effect draws, as open and closed paths that
|
||||
//! can be edited on their own.
|
||||
//! can be edited on their own. Also the 3D and Materials panel's Render Settings › Wireframe, which
|
||||
//! draws those edges in place of the shaded faces while the effect stays live.
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineJoin, Node, NodeId, StrokeLayer};
|
||||
use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath};
|
||||
use vectorcraft_three_d::Facet;
|
||||
|
||||
use super::{
|
||||
CLASSIC_EXTRUDE, CLASSIC_ROTATE, Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of,
|
||||
members, object_profile, region, reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds,
|
||||
CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane,
|
||||
is_three_d, label_of, members, object_profile, region, reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts,
|
||||
union_bounds,
|
||||
};
|
||||
use crate::{GeomContext, is_geometry};
|
||||
|
||||
/// The most distinct edges one object's wireframe may have.
|
||||
pub(crate) const MAX_WIRE_EDGES: usize = 1_000_000;
|
||||
|
||||
/// The parameter that holds a 3D effect's render settings: an object whose `mode` is one of
|
||||
/// [`RENDER_MODES`].
|
||||
pub const RENDER: &str = "render";
|
||||
|
||||
/// The render modes of a 3D effect: `shaded` draws the lit faces, `wireframe` the edges of every
|
||||
/// face ([`renders_wireframe`]).
|
||||
pub const RENDER_MODES: [&str; 2] = ["shaded", "wireframe"];
|
||||
|
||||
/// The 3D effects that take [`RENDER`]: the 3D and Materials panel's Extrude & Bevel, Revolve,
|
||||
/// Inflate and Rotate. The 3D (Classic) effects have their own Wireframe surface.
|
||||
const TAKES_RENDER: [&str; 4] = [EXTRUDE, crate::REVOLVE, INFLATE, ROTATE];
|
||||
|
||||
/// The weight of a rendered wireframe edge, in points: the weight Expand as Wireframes strokes its
|
||||
/// edges with (VectorCraft's own).
|
||||
pub(crate) const WIRE_WEIGHT: f64 = 0.5;
|
||||
|
||||
/// Does effect `fx` draw as a wireframe: is it one of the effects that take [`RENDER`], with its
|
||||
/// `render.mode` set to `wireframe`?
|
||||
pub fn renders_wireframe(fx: &Effect) -> bool {
|
||||
wireframe_params(&fx.id, &fx.params)
|
||||
}
|
||||
|
||||
/// Do 3D effect `id`'s parameters `p` set it to draw as a wireframe ([`renders_wireframe`])? A
|
||||
/// command that sets them is checked against the art that mode draws: the faces' edges, with no
|
||||
/// shadow, graphics or paint copies.
|
||||
pub(crate) fn wireframe_params(id: &str, p: &Value) -> bool {
|
||||
TAKES_RENDER.contains(&id) && p.get(RENDER).and_then(|r| r.get("mode")).and_then(Value::as_str) == Some("wireframe")
|
||||
}
|
||||
|
||||
/// Check the render settings in 3D effect `id`'s parameters `p`: `render`, when given to one of the
|
||||
/// effects in [`TAKES_RENDER`], is an object that holds only `mode`, one of [`RENDER_MODES`] (a
|
||||
/// missing `mode` is `shaded`). The 3D (Classic) effects refuse it as a parameter they do not take.
|
||||
pub(crate) fn validate_render(id: &str, p: &Value) -> Result<(), String> {
|
||||
let Some(render) = p.get(RENDER).filter(|_| TAKES_RENDER.contains(&id)) else { return Ok(()) };
|
||||
let valid = render
|
||||
.as_object()
|
||||
.is_some_and(|fields| fields.iter().all(|(key, value)| key == "mode" && value.as_str().is_some_and(|m| RENDER_MODES.contains(&m))));
|
||||
if !valid {
|
||||
return Err("`render` must be an object holding only `mode`, set to shaded or wireframe".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The art a 3D effect in wireframe mode draws for object `id` in place of its shaded faces: a group
|
||||
/// of the edges of `faces`, each edge once ([`edges`]), stroked [`WIRE_WEIGHT`] black with round
|
||||
/// joins and no fill, as Expand as Wireframes strokes them. Like Expand as Wireframes and the
|
||||
/// Outline view, the mesh shows every edge whatever the opacity, blending mode or opacity mask of
|
||||
/// the fills, strokes and group members it is built from; the object's own transparency applies to
|
||||
/// the art as a whole. `None` past [`MAX_WIRE_EDGES`] edges, when the canvas draws the source art,
|
||||
/// as it does for a solid past its limits.
|
||||
pub(crate) fn wire_art(id: NodeId, faces: &[PathData]) -> Option<Arc<Node>> {
|
||||
let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), WIRE_WEIGHT);
|
||||
stroke.join = LineJoin::Round;
|
||||
let appearance = Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Default::default() };
|
||||
let children = edges(faces).ok()?.into_iter().map(|path| Arc::new(Node::path(id, path, appearance.clone()))).collect();
|
||||
Some(Arc::new(Node::group(id, children)))
|
||||
}
|
||||
|
||||
/// The art 3D effect `fx` in wireframe mode draws for object `id` from `shapes`: the edges of the
|
||||
/// faces it builds ([`wire_art`]). `None` when a solid can't be built or past the edge limit, when
|
||||
/// the canvas draws the source art.
|
||||
pub(crate) fn wire_surfaces(id: NodeId, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
|
||||
let faces: Vec<PathData> = facet_paths(fx, shapes).ok()?.into_iter().map(|f| f.path).collect();
|
||||
Some(vec![wire_art(id, &faces)?])
|
||||
}
|
||||
|
||||
/// The grid points are rounded to before edges are compared, in points: two faces that share an
|
||||
/// edge give it once.
|
||||
const GRID: f64 = 1.0 / 1024.0;
|
||||
@@ -171,7 +240,7 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<FacesAndShadow,
|
||||
/// as it is left out of the drawn art; past the limits of the object's solids together, the result
|
||||
/// is an error, as the canvas draws the source art. Each facet carries the width of the stroke the
|
||||
/// canvas draws around it.
|
||||
fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result<Vec<WireFace>, String> {
|
||||
pub(crate) fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result<Vec<WireFace>, String> {
|
||||
let label = label_of(&fx.id);
|
||||
let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
|
||||
let p = crate::merged_params(&fx.id, &fx.params);
|
||||
|
||||
@@ -281,6 +281,210 @@ fn a_clipping_group_with_revolve_and_rotate_expands_the_art_revolve_cuts() {
|
||||
assert_eq!(both, alone);
|
||||
}
|
||||
|
||||
/// The leaves `art` draws: each path with its fill and stroke.
|
||||
fn leaves(art: &Node) -> Vec<(PathData, bool, Option<StrokeLayer>)> {
|
||||
let mut out = vec![];
|
||||
art.walk(&mut |c| {
|
||||
if let Some(p) = c.path_data() {
|
||||
out.push((p.clone(), c.appearance.fill().is_some(), c.appearance.stroke().cloned()));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The art the canvas draws for `n`: Revolve's, else the other 3D effects'.
|
||||
fn drawn(n: &Node) -> Node {
|
||||
crate::revolve_art(n).or_else(|| super::super::solid_art(n)).unwrap_or_else(|| panic!("no art for {n:?}"))
|
||||
}
|
||||
|
||||
const WIRE: [&str; 4] = [super::super::EXTRUDE, crate::REVOLVE, super::super::INFLATE, ROTATE];
|
||||
|
||||
/// Render Settings › Wireframe: each of the panel's four effects draws the edges Expand as
|
||||
/// Wireframes gives, each a 0.5 pt black stroke with round joins and no fill, in place of its
|
||||
/// shaded faces; shaded again, it draws filled faces.
|
||||
#[test]
|
||||
fn wireframe_mode_draws_the_expanded_edges_as_strokes() {
|
||||
for id in WIRE {
|
||||
let n = solid_square(id, json!({"render": {"mode": "wireframe"}}));
|
||||
let art = leaves(&drawn(&n));
|
||||
let wire = three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
assert_eq!(art.len(), wire.len(), "{id}");
|
||||
for ((path, filled, stroke), edge) in art.iter().zip(&wire) {
|
||||
assert!(!filled, "{id}: an edge has no fill");
|
||||
let stroke = stroke.as_ref().unwrap_or_else(|| panic!("{id}: an edge is stroked"));
|
||||
assert_eq!((stroke.width, stroke.join, &stroke.paint), (WIRE_WEIGHT, LineJoin::Round, &Paint::solid(Color::BLACK)), "{id}");
|
||||
assert_eq!(segments(std::slice::from_ref(path)), segments(std::slice::from_ref(edge)), "{id}");
|
||||
}
|
||||
let shaded = leaves(&drawn(&solid_square(id, json!({"render": {"mode": "shaded"}}))));
|
||||
assert!(shaded.iter().any(|(_, filled, _)| *filled), "{id}: shaded faces are filled");
|
||||
}
|
||||
// The box turned off axis shows the nine edges of its three faces.
|
||||
let n = solid_square(super::super::EXTRUDE, json!({"render": {"mode": "wireframe"}}));
|
||||
let paths: Vec<PathData> = leaves(&drawn(&n)).into_iter().map(|(p, ..)| p).collect();
|
||||
assert_eq!(segments(&paths).len(), 9);
|
||||
}
|
||||
|
||||
/// The wireframe stays live: a deeper extrusion and a turned view redraw the edges.
|
||||
#[test]
|
||||
fn wireframe_mode_follows_the_effects_settings() {
|
||||
let paths = |p: Value| -> Vec<PathData> { leaves(&drawn(&solid_square(super::super::EXTRUDE, p))).into_iter().map(|(p, ..)| p).collect() };
|
||||
let flat = bounds(&paths(json!({"render": {"mode": "wireframe"}, "depth": 10.0})));
|
||||
let deep = bounds(&paths(json!({"render": {"mode": "wireframe"}, "depth": 200.0})));
|
||||
assert!(deep.width() > flat.width() + 1.0 || deep.height() > flat.height() + 1.0, "{flat:?} {deep:?}");
|
||||
let turned = paths(json!({"render": {"mode": "wireframe"}, "rotationY": 60.0}));
|
||||
assert_ne!(segments(&turned), segments(&paths(json!({"render": {"mode": "wireframe"}}))));
|
||||
}
|
||||
|
||||
/// In wireframe mode no shadow and no graphic is drawn: only the edges.
|
||||
#[test]
|
||||
fn wireframe_mode_draws_no_shadow() {
|
||||
for id in WIRE {
|
||||
let n = solid_square(id, json!({"render": {"mode": "wireframe"}, "shadow": true}));
|
||||
let art = leaves(&drawn(&n));
|
||||
assert!(!art.is_empty() && art.iter().all(|(_, filled, stroke)| !filled && stroke.is_some()), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode works where the effect is on a fill and on a group: the fill's solid and every member's
|
||||
/// solid draw as edges, and the geometry effects after a fill's effect still apply to them.
|
||||
#[test]
|
||||
fn wireframe_mode_on_a_fill_and_on_a_group() {
|
||||
for id in WIRE {
|
||||
let mut n = solid_square(id, json!({}));
|
||||
let mut fx = n.appearance.effects.remove(0);
|
||||
fx.params["render"] = json!({"mode": "wireframe"});
|
||||
let fill = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))).unwrap();
|
||||
fill.effects_mut().push(fx.clone());
|
||||
fill.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 500.0})).unwrap());
|
||||
let art = leaves(&drawn(&n));
|
||||
assert!(!art.is_empty() && art.iter().all(|(_, filled, stroke)| !filled && stroke.is_some()), "{id} on a fill");
|
||||
|
||||
let member = |x: f64, id: u64| {
|
||||
Arc::new(Node::path(NodeId(id), square(x, 100.0, 50.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)))
|
||||
};
|
||||
let mut g = Node::group(NodeId(1), vec![member(100.0, 2), member(200.0, 3)]);
|
||||
g.appearance.effects.push(fx);
|
||||
let art = leaves(&drawn(&g));
|
||||
assert!(!art.is_empty() && art.iter().all(|(_, filled, stroke)| !filled && stroke.is_some()), "{id} on a group");
|
||||
// Both members' edges are drawn.
|
||||
let paths: Vec<PathData> = art.into_iter().map(|(p, ..)| p).collect();
|
||||
assert!(bounds(&paths).width() > 100.0, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The mesh shows every edge, as Expand as Wireframes gives them, whatever the opacity of the fill
|
||||
/// and the opacity masks of the group's members; the object's own opacity stays on the art. Two
|
||||
/// members that touch, one at 50% and one masked, draw their shared edge once: seven edges.
|
||||
#[test]
|
||||
fn the_mesh_shows_every_edge_whatever_the_members_transparency() {
|
||||
let member = |x: f64, id: u64, opacity: f32| {
|
||||
let mut n = Node::path(NodeId(id), square(x, 100.0, 50.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
|
||||
f.opacity = opacity;
|
||||
}
|
||||
n
|
||||
};
|
||||
let mut masked = member(150.0, 3, 1.0);
|
||||
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(member(0.0, 900, 1.0), true)));
|
||||
let mut g = Node::group(NodeId(1), vec![Arc::new(member(100.0, 2, 0.5)), Arc::new(masked)]);
|
||||
g.opacity = 0.75;
|
||||
g.appearance.effects.push(crate::new_effect(ROTATE, &json!({"render": {"mode": "wireframe"}})).unwrap());
|
||||
let art = drawn(&g);
|
||||
assert_eq!(art.opacity, 0.75);
|
||||
let paths: Vec<PathData> = leaves(&art).into_iter().map(|(p, ..)| p).collect();
|
||||
assert_eq!(segments(&paths).len(), 7, "{paths:?}");
|
||||
assert_eq!(segments(&paths).len(), segments(&three_d_wireframe_in(None, &g).unwrap()).len());
|
||||
let mut scoped = 0;
|
||||
for c in art.children().into_iter().flatten() {
|
||||
c.walk(&mut |c| scoped += usize::from(c.extras().mask.is_some() || c.opacity < 1.0));
|
||||
}
|
||||
assert_eq!(scoped, 0, "no part of the mesh is masked or faded");
|
||||
// A fill at 0% still shows its mesh.
|
||||
for id in WIRE {
|
||||
let mut n = solid_square(id, json!({"render": {"mode": "wireframe"}}));
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
|
||||
f.opacity = 0.0;
|
||||
}
|
||||
assert!(!leaves(&drawn(&n)).is_empty(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand Appearance of a Revolve in wireframe mode gives the stroked edges, not the shaded faces.
|
||||
#[test]
|
||||
fn expanding_a_revolve_in_wireframe_mode_gives_the_edges() {
|
||||
let n = solid_square(crate::REVOLVE, json!({"render": {"mode": "wireframe"}}));
|
||||
let art = leaves(&crate::revolve::expand_revolve_art(&n).unwrap());
|
||||
assert_eq!(art.len(), three_d_wireframe_in(None, &n).unwrap().len());
|
||||
assert!(art.iter().all(|(_, filled, stroke)| !filled && stroke.as_ref().is_some_and(|s| s.width == WIRE_WEIGHT)));
|
||||
}
|
||||
|
||||
/// `render` is checked before an edit: an object holding only `mode`, `shaded` or `wireframe`, and
|
||||
/// only on the panel's four effects.
|
||||
#[test]
|
||||
fn render_settings_are_validated() {
|
||||
for id in WIRE {
|
||||
for good in [json!({}), json!({"render": {}}), json!({"render": {"mode": "shaded"}}), json!({"render": {"mode": "wireframe"}})] {
|
||||
assert_eq!(crate::validate_three_d_params(id, &good), Ok(()), "{id} {good}");
|
||||
}
|
||||
for bad in [
|
||||
json!({"render": "wireframe"}),
|
||||
json!({"render": null}),
|
||||
json!({"render": {"mode": "raytrace"}}),
|
||||
json!({"render": {"mode": true}}),
|
||||
json!({"render": {"mode": "wireframe", "quality": "high"}}),
|
||||
] {
|
||||
let e = crate::validate_three_d_params(id, &bad).unwrap_err();
|
||||
assert_eq!(e, "`render` must be an object holding only `mode`, set to shaded or wireframe", "{id} {bad}");
|
||||
}
|
||||
// The command check on the object refuses it too.
|
||||
let n = solid_square(id, json!({}));
|
||||
assert!(crate::validate_three_d(&n, id, &json!({"render": {"mode": "x"}})).is_err(), "{id}");
|
||||
}
|
||||
for id in [super::super::CLASSIC_EXTRUDE, crate::CLASSIC_REVOLVE, super::super::CLASSIC_ROTATE] {
|
||||
let e = crate::validate_three_d_params(id, &json!({"render": {"mode": "wireframe"}})).unwrap_err();
|
||||
assert!(e.contains("`render`"), "{id}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A Revolve whose shaded surface is past the budget of paint copies (a gradient with many stops) is
|
||||
/// refused shaded, but in wireframe mode, which draws only the black edges, it is accepted and drawn:
|
||||
/// on the object and on a fill.
|
||||
#[test]
|
||||
fn wireframe_mode_is_checked_against_the_edges_it_draws() {
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let n = solid_square(crate::REVOLVE, json!({}));
|
||||
let path = n.path_data().unwrap().clone();
|
||||
let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), crate::revolve::revolve_options(&json!({}))).unwrap().len();
|
||||
let stops = crate::revolve::MAX_PAINT_COPIES / (faces * 4) / std::mem::size_of::<GradientStop>() + 1;
|
||||
let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect();
|
||||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||
let mut n = n;
|
||||
n.appearance = Appearance::basic(gradient, Paint::None, 0.0).into();
|
||||
let shaded = json!({});
|
||||
let wire = json!({"render": {"mode": "wireframe"}});
|
||||
let refused = crate::validate_revolve(&n, &shaded).unwrap_err();
|
||||
assert!(refused.contains("simplify the gradient"), "{refused}");
|
||||
assert_eq!(crate::validate_revolve(&n, &wire), Ok(()));
|
||||
n.appearance.effects.push(crate::new_effect(crate::REVOLVE, &wire).unwrap());
|
||||
assert!(leaves(&drawn(&n)).iter().all(|(_, filled, stroke)| !filled && stroke.is_some()));
|
||||
// On the fill.
|
||||
let fx = n.appearance.effects.remove(0);
|
||||
let at = n.appearance.items.iter().position(|i| matches!(i, AppearanceItem::Fill(_))).unwrap();
|
||||
assert!(crate::revolve::validate_item_revolve(&n, &shaded, at, 0).is_err());
|
||||
assert_eq!(crate::revolve::validate_item_revolve(&n, &wire, at, 0), Ok(()));
|
||||
n.appearance.items[at].effects_mut().push(fx);
|
||||
assert!(leaves(&drawn(&n)).iter().all(|(_, filled, stroke)| !filled && stroke.is_some()));
|
||||
}
|
||||
|
||||
/// A 3D (Classic) effect ignores a `render` it does not take in a loaded file: it draws as before.
|
||||
#[test]
|
||||
fn a_classic_effect_is_not_drawn_as_a_render_wireframe() {
|
||||
let mut n = solid_square(super::super::CLASSIC_EXTRUDE, json!({}));
|
||||
n.appearance.effects[0].params["render"] = json!({"mode": "wireframe"});
|
||||
assert!(!renders_wireframe(&n.appearance.effects[0]));
|
||||
assert!(leaves(&drawn(&n)).iter().any(|(_, filled, _)| *filled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_without_a_visible_3d_effect_is_an_error() {
|
||||
let mut n = solid_square(super::super::EXTRUDE, json!({}));
|
||||
|
||||
@@ -43,6 +43,7 @@ pub(crate) mod graph;
|
||||
mod halftone;
|
||||
pub mod help;
|
||||
pub mod importmatches;
|
||||
mod info;
|
||||
pub(crate) mod inline;
|
||||
mod interactivity;
|
||||
mod intertwine;
|
||||
@@ -344,6 +345,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(docmenu::specs());
|
||||
v.extend(variablecmds::specs());
|
||||
v.extend(docinfo::specs());
|
||||
v.extend(info::specs());
|
||||
v.extend(panelcmds::specs());
|
||||
v.extend(artboardalign::specs());
|
||||
v.extend(buildcmds::specs());
|
||||
@@ -828,6 +830,7 @@ pub(crate) use edit::{settle_interaction, typing_in_progress};
|
||||
pub(crate) use object::bounds_rewrite;
|
||||
pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned};
|
||||
pub use plugin_call::{Location, ParamsPlace, key_location, location_in, location_keys};
|
||||
pub use select::SIMILAR;
|
||||
pub(crate) use vectorcraft_doc::range::parse_range;
|
||||
|
||||
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
|
||||
|
||||
@@ -422,6 +422,15 @@ pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result<Opti
|
||||
Ok(ids.first().copied().filter(|id| super::graph::is_design_art(d, *id)))
|
||||
}
|
||||
|
||||
/// The [`design_reference`] of an edit aimed at `item`: none for the Appearance panel's active row, which is a row of the
|
||||
/// first selected object's stack.
|
||||
pub(crate) fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result<Option<NodeId>> {
|
||||
if matches!(item, ItemTarget::Item { explicit: false, .. }) {
|
||||
return Ok(None);
|
||||
}
|
||||
design_reference(s, p, cmd)
|
||||
}
|
||||
|
||||
/// `ids` after `of` (see [`design_reference`]), when there is one.
|
||||
pub(crate) fn with_reference(of: Option<NodeId>, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
of.into_iter().chain(ids.iter().copied()).collect()
|
||||
@@ -610,16 +619,23 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
/// ([`capture_series_changes`], [`super::graph::capture_label_changes`]). Nothing targeted is not
|
||||
/// an edit, and when `f` leaves every target as it was, the document and its undo history stay as
|
||||
/// they are.
|
||||
///
|
||||
/// With `of` (see [`reference_of`]), an explicit aligned `item` is a row of the stack of `of`, which
|
||||
/// is not among `ids`: it must be there and of the edited kind, each target edits the row equal to
|
||||
/// it, a target without one is left as it is, and no target with one is an error. Returns the
|
||||
/// targets `f` was aimed at, in the order of `ids`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn edit_items(
|
||||
s: &mut Session,
|
||||
ids: &[NodeId],
|
||||
item: ItemTarget,
|
||||
of: Option<NodeId>,
|
||||
cmd: &str,
|
||||
label: &str,
|
||||
fill: bool,
|
||||
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| {
|
||||
) -> Result<Vec<NodeId>> {
|
||||
edit_items_then(s, ids, item, of, cmd, label, fill, f, |before, d, edited| {
|
||||
let series = capture_series_changes(before, d, edited);
|
||||
// The rows `f` edited are kept by `edit_items_then`.
|
||||
let labels = super::graph::capture_label_changes(before, d, edited, false);
|
||||
@@ -636,17 +652,29 @@ pub(crate) fn edit_items_then(
|
||||
s: &mut Session,
|
||||
ids: &[NodeId],
|
||||
item: ItemTarget,
|
||||
of: Option<NodeId>,
|
||||
cmd: &str,
|
||||
label: &str,
|
||||
fill: bool,
|
||||
mut f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
|
||||
mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result<bool>,
|
||||
) -> Result<()> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
) -> Result<Vec<NodeId>> {
|
||||
let art = of.and_then(|of| s.doc().ok()?.doc.node(of).map(|n| n.appearance.clone()));
|
||||
let per = match (item, art) {
|
||||
// Checked even when the art is the only object given, which leaves `ids` empty.
|
||||
(ItemTarget::Item { index, explicit: true, aligned: true }, Some(art)) => {
|
||||
item.resolve(&art, fill, cmd)?;
|
||||
let per = item.per_target_of(s, of, ids);
|
||||
if per.iter().all(Option::is_none) {
|
||||
return Err(bad(cmd, format!("no appearance item {index}")));
|
||||
}
|
||||
per
|
||||
}
|
||||
_ if ids.is_empty() => return Ok(Vec::new()),
|
||||
_ => item.per_target(s, ids),
|
||||
};
|
||||
let aimed: Vec<NodeId> = ids.iter().zip(&per).filter(|(_, item)| item.is_some()).map(|(id, _)| *id).collect();
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = item.per_target(s, ids);
|
||||
super::overprint::edit_counted(s, label, |d| {
|
||||
let mut edited = Vec::new();
|
||||
// The objects whose rows `f` edited: on type, no row means its characters.
|
||||
@@ -665,7 +693,13 @@ pub(crate) fn edit_items_then(
|
||||
let labels = super::graph::capture_label_rows(d, &rows);
|
||||
Ok(count_changed(d, &before, &edited) + usize::from(more || labels))
|
||||
})?;
|
||||
Ok(())
|
||||
Ok(aimed)
|
||||
}
|
||||
|
||||
/// The first of `ids` that [`edit_items`] aimed at `item` with `of` changes, and the item it changes there. Read it
|
||||
/// before the edit: once edited, a row that was equal to a row of the art of `of` no longer is.
|
||||
pub(crate) fn first_edited(s: &Session, item: ItemTarget, of: Option<NodeId>, ids: &[NodeId]) -> Option<(NodeId, ItemTarget)> {
|
||||
ids.iter().zip(item.per_target_of(s, of, ids)).find_map(|(id, it)| Some((*id, it?)))
|
||||
}
|
||||
|
||||
/// How many of `ids` differ in `d` from `before`.
|
||||
@@ -674,8 +708,8 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_
|
||||
}
|
||||
|
||||
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
|
||||
/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`]), else `default`. A `stroke` other than true, false or
|
||||
/// null is an error of command `cmd`.
|
||||
/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`] and [`reference_of`]), else `default`. A `stroke` other than
|
||||
/// true, false or null is an error of command `cmd`.
|
||||
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result<bool> {
|
||||
check_switches(p, &["stroke"], cmd)?;
|
||||
if let Some(b) = p.get("stroke").and_then(Value::as_bool) {
|
||||
@@ -683,8 +717,14 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo
|
||||
}
|
||||
let ItemTarget::Item { index, .. } = item else { return Ok(default) };
|
||||
let d = &s.doc()?.doc;
|
||||
let ids = drop_design_art(d, appearance_targets(s, p)?);
|
||||
Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
|
||||
let holder = match reference_of(s, p, item, cmd)? {
|
||||
Some(of) => Some(of),
|
||||
None => {
|
||||
let ids = drop_design_art(d, appearance_targets(s, p)?);
|
||||
ids.get(item.anchor(s, &ids)).copied()
|
||||
}
|
||||
};
|
||||
Ok(holder.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 [`u64_req`] and [`index`].
|
||||
|
||||
@@ -32,7 +32,7 @@ use crate::DocState;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = vec![
|
||||
cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty)", always, export_svg),
|
||||
cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty). Under automation read roots it is refused while the SVG would name a linked image's file outside them (an image whose pixels the clipboard doesn't hold; a relative path is followed from the document the objects came from while it is open)", always, export_svg),
|
||||
cmd!(
|
||||
query "clipboard.importSvg",
|
||||
"Load SVG into Clipboard",
|
||||
@@ -435,14 +435,37 @@ impl Clipboard {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The internal clipboard as standalone SVG (`None` when it is empty).
|
||||
/// The internal clipboard as standalone SVG (`None` when it is empty, and when the SVG would name
|
||||
/// a linked image's file the caller asking now may not be shown: [`Self::clipboard_svg_refused`]).
|
||||
pub fn clipboard_svg(&self) -> Option<String> {
|
||||
if self.clipboard_svg_refused() {
|
||||
return None;
|
||||
}
|
||||
self.clipboard.svg()
|
||||
}
|
||||
|
||||
/// Would the internal clipboard's SVG name a linked image's file outside the read roots in
|
||||
/// force? It names the file of an image whose pixels the clipboard doesn't hold. A relative
|
||||
/// path is followed from the document the objects came from while it is open; otherwise it
|
||||
/// counts as outside.
|
||||
pub fn clipboard_svg_refused(&self) -> bool {
|
||||
self.check_clipboard_svg("clipboard.exportSvg").is_err()
|
||||
}
|
||||
|
||||
/// [`Self::clipboard_svg_refused`] as `cmd`'s error, which names no file or folder.
|
||||
fn check_clipboard_svg(&self, cmd: &str) -> Result<()> {
|
||||
if crate::file_access::current().is_none() || !self.clipboard.has_art() {
|
||||
return Ok(());
|
||||
}
|
||||
let from = self.docs.iter().find(|d| Some(d.uid) == self.clipboard.source_doc);
|
||||
let base = from.map_or_else(super::links::LinkBase::default, super::links::LinkBase::of);
|
||||
super::fileio::check_images_in(cmd, "svg", &self.clipboard.to_document(), base, &json!({}))
|
||||
}
|
||||
}
|
||||
|
||||
fn export_svg(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "svg": s.clipboard_svg() }))
|
||||
s.check_clipboard_svg("clipboard.exportSvg")?;
|
||||
Ok(json!({ "svg": s.clipboard.svg() }))
|
||||
}
|
||||
|
||||
fn import_svg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -14,7 +14,8 @@ use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::appearance::{
|
||||
ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, with_reference,
|
||||
ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, reference_of,
|
||||
with_reference,
|
||||
};
|
||||
use super::expand::has_seeded;
|
||||
use super::rasterfx;
|
||||
@@ -336,15 +337,6 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us
|
||||
/// have.
|
||||
const APPENDED: usize = usize::MAX;
|
||||
|
||||
/// The [`design_reference`] of an effect edit aimed at `item`: none for the Appearance panel's active row, which is a
|
||||
/// row of the first selected object's stack.
|
||||
fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result<Option<NodeId>> {
|
||||
if matches!(item, ItemTarget::Item { explicit: false, .. }) {
|
||||
return Ok(None);
|
||||
}
|
||||
design_reference(s, p, cmd)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn edit_effects(
|
||||
s: &mut Session,
|
||||
|
||||
@@ -125,9 +125,10 @@ mod commands;
|
||||
mod export_names;
|
||||
mod formats;
|
||||
|
||||
pub(crate) use access::check_images_in;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use access::list_dir;
|
||||
pub use access::{check_linked_scripts, write_file_with, write_save_file, write_save_file_with};
|
||||
pub use access::{check_linked_images, check_linked_scripts, write_file_with, write_save_file, write_save_file_with};
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests_readonly;
|
||||
|
||||
@@ -139,6 +139,49 @@ pub fn check_linked_scripts(cmd: &str, format: &str, st: &crate::DocState) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The error for an SVG that would name a linked image's file outside the read roots.
|
||||
pub(crate) const IMAGE_OUTSIDE: &str = "a linked image file is outside the read roots";
|
||||
|
||||
/// `cmd` may write `d`, the document an output of `st` writes (all of it, or the objects it
|
||||
/// exports), in format `format` (a format id) with options `p`: any format but SVG and SVGZ, and
|
||||
/// those too unless read roots are in force and the file would name a linked image's file outside
|
||||
/// them (a relative path taken from `st`'s folder; a URL is not checked). An `<image>` names the
|
||||
/// file with SVG Options › Images set to Link, and with Embed when the document holds no pixels for
|
||||
/// the image, for the images of the art each file draws (with chosen artboards, the art over each
|
||||
/// one; hidden layers and objects only with `hiddenLayers`, template layers and the opacity-mask
|
||||
/// and symbol editing layers never) and of the symbols, patterns and brushes that art uses; with
|
||||
/// Preserve Editing Capabilities, the native document the file carries names every linked file, the relative paths kept of them
|
||||
/// and each data set's image. The error names no file or folder.
|
||||
pub fn check_linked_images(cmd: &str, format: &str, st: &crate::DocState, d: &vectorcraft_doc::Document, p: &Value) -> Result<()> {
|
||||
check_images_in(cmd, format, d, crate::cmd::links::LinkBase::of(st), p)
|
||||
}
|
||||
|
||||
/// [`check_linked_images`] for `d`, a document whose links are looked for from `base` (the
|
||||
/// objects on the clipboard, looked for from the document they came from).
|
||||
pub(crate) fn check_images_in(cmd: &str, format: &str, d: &vectorcraft_doc::Document, base: crate::cmd::links::LinkBase, p: &Value) -> Result<()> {
|
||||
if !matches!(format, "svg" | "svgz") || crate::file_access::current().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
// Options that don't read as the encoder's choices count as Link, Preserve Editing and hidden
|
||||
// layers kept: the encoder refuses them anyway.
|
||||
let (link, preserve, hidden) = match super::svg::options_map(p) {
|
||||
Ok(m) => {
|
||||
let on = |k: &str| m.get(k).is_some_and(|v| v.as_bool() != Some(false));
|
||||
(m.get("images").is_some_and(|v| v.as_str() != Some("embed")), on("preserveEditing"), on("hiddenLayers"))
|
||||
}
|
||||
Err(_) => (true, true, true),
|
||||
};
|
||||
// The opacity-mask and symbol editing layers are never written.
|
||||
let d = &*d.without_edit_modes();
|
||||
let names = |d: &vectorcraft_doc::Document| crate::cmd::links::svg_names_hidden_image(d, base, link, hidden);
|
||||
if preserve && crate::cmd::links::names_hidden_file(d, base)
|
||||
|| names(d) && super::svg::drawn(d, p).is_none_or(|files| files.iter().any(|f| names(f)))
|
||||
{
|
||||
return Err(bad(cmd, IMAGE_OUTSIDE));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `path` checked for writing against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn may_write(path: &str) -> Result<()> {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,8 +12,8 @@ use crate::DocState;
|
||||
use super::super::{Result, Session, bad, check_switches, edit, number_or, switch_or, text_param};
|
||||
use super::encode::single_artboard;
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, Format, check_linked_scripts, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded,
|
||||
write_or_return,
|
||||
ARTBOARD_PARAMS, Format, check_images_in, check_linked_scripts, default_name, encode_all, encode_with_warnings, merge, with_black_output,
|
||||
writable, write_encoded, write_or_return,
|
||||
};
|
||||
|
||||
/// The keys the export commands take for every format, beside the path, the format and the
|
||||
@@ -287,6 +287,8 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let doc = export_doc(st, f, p)?;
|
||||
// The objects written (all, or with selectedOnly the selected ones) are checked.
|
||||
check_images_in(cmd, f.id, &doc, crate::cmd::links::LinkBase::of(st), p)?;
|
||||
let display_doc_title = super::has_own_title(&doc);
|
||||
Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title))
|
||||
}
|
||||
@@ -372,6 +374,8 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let expanded = expand_presets(s, C, p)?;
|
||||
let p = with_black_output(s, &expanded);
|
||||
let (d, bounds) = selection(s, C, f.id == "txt")?;
|
||||
// The selected objects' links are looked for from the document they are in.
|
||||
check_images_in(C, f.id, &d, crate::cmd::links::LinkBase::of(s.doc()?), &without_artboards(&p))?;
|
||||
// The encoder's warnings (options accepted but not applied, features approximated or left
|
||||
// out), as document.export reports them.
|
||||
let (bytes, mut warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?;
|
||||
|
||||
@@ -393,8 +393,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
return Err(err(format!("can't open `{}`: {}", file_name(name), crate::cmd::place::dcs::LINK_ONLY)));
|
||||
}
|
||||
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
|
||||
// A `.ai` keeps the artboard its header gives; an EPS comes in on its page.
|
||||
let import = if matches!(format.id, "ai" | "ait") { vectorcraft_eps::import_ai } else { vectorcraft_eps::import_names };
|
||||
restore_or_import(editing, || {
|
||||
let r = vectorcraft_eps::import_names(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names)
|
||||
let r = import(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names)
|
||||
.map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
|
||||
Ok((r.document, r.warnings))
|
||||
})?
|
||||
|
||||
@@ -891,6 +891,13 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
|
||||
let cmd = plan.mode.command();
|
||||
super::check_linked_scripts(cmd, plan.format.id, st)?;
|
||||
let mut own = doc_to_save(st, plan.format);
|
||||
let mut params = plan.options.clone();
|
||||
if is_svg(plan.format) {
|
||||
// A save keeps hidden layers (not displayed) unless told otherwise; exports leave them out.
|
||||
params.entry("hiddenLayers").or_insert(Value::Bool(true));
|
||||
}
|
||||
let params = Value::Object(params);
|
||||
super::check_linked_images(cmd, plan.format.id, st, &own, ¶ms)?;
|
||||
// The file the document is saved as (else its own) titles it if New's name is all it has.
|
||||
let file = plan.path.as_deref().filter(|_| plan.retargets()).or(st.path.as_deref());
|
||||
if let Some(d) = super::titled_for_export(&own, file, plan.format.id) {
|
||||
@@ -905,12 +912,7 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
|
||||
let base = crate::cmd::links::LinkBase::of(st);
|
||||
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, base, p));
|
||||
let doc = relative.map_or(own, Arc::new);
|
||||
let mut params = plan.options.clone();
|
||||
if is_svg(plan.format) {
|
||||
// A save keeps hidden layers (not displayed) unless told otherwise; exports leave them out.
|
||||
params.entry("hiddenLayers").or_insert(Value::Bool(true));
|
||||
}
|
||||
let mut params = Value::Object(params);
|
||||
let mut params = params;
|
||||
if matches!(plan.format.id, "vectorcraft" | "template") {
|
||||
// Not remembered with the options: the preference decides each time it isn't given.
|
||||
params = with_compression_pref(&s.prefs, ¶ms);
|
||||
@@ -1307,7 +1309,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 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",
|
||||
"{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), or while the file would name a linked image's file outside them (images: link, preserveEditing, or an image whose pixels the document doesn't hold; 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)
|
||||
),
|
||||
|
||||
@@ -1125,6 +1125,13 @@ pub(crate) fn export(s: &mut Session, p: &Value, asked: &Value) -> Result<Value>
|
||||
};
|
||||
let crop = rows.iter().any(|r| r.format.id != "pdf");
|
||||
let items = items(&doc, &o, selection, &selected, &naming.filename, crop, true)?;
|
||||
// The file each item writes for each SVG row, before the folder is made or any row is written.
|
||||
let base = crate::cmd::links::LinkBase::of(s.doc()?);
|
||||
for r in rows.iter().filter(|r| matches!(r.format.id, "svg" | "svgz")) {
|
||||
for item in &items {
|
||||
super::check_images_in(C, r.format.id, &item.doc, base, &r.options_for(item.region, item.artboard)?)?;
|
||||
}
|
||||
}
|
||||
// Appearance of Black › Printing / Exporting for RGB raster rows.
|
||||
// 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!({}) };
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! params' top level or from an `svg` object (which wins), and the files an SVG export writes
|
||||
//! (one per artboard, plus the images it links to).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use vectorcraft_doc::Document;
|
||||
@@ -155,6 +157,18 @@ fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>,
|
||||
Ok((opts, named.unwrap_or_else(|| vec![0]).into_iter().map(Some).collect(), chosen))
|
||||
}
|
||||
|
||||
/// The art the SVG files of `doc` with options `p` draw, as [`encode`] picks it: with chosen
|
||||
/// artboards, the art over each one; else all of `doc`. `None` when the options don't read.
|
||||
pub(super) fn drawn<'a>(doc: &'a Document, p: &Value) -> Option<Vec<Cow<'a, Document>>> {
|
||||
let (_, boards, chosen) = plan(doc, p).ok()?;
|
||||
if !chosen {
|
||||
return Some(vec![Cow::Borrowed(doc)]);
|
||||
}
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let rects: Vec<_> = boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect();
|
||||
Some(rects.into_iter().map(|r| Cow::Owned(super::export::art_over(doc, r, &mut renderer))).collect())
|
||||
}
|
||||
|
||||
/// What a save of `doc` as SVG (format `label`) with options `p` loses of several artboards: the
|
||||
/// file is one artboard and reopens as one, unless it carries the native document (Preserve
|
||||
/// Editing Capabilities).
|
||||
|
||||
@@ -277,3 +277,17 @@ fn ps_and_epsf_files_open_and_place() {
|
||||
let e = writable_format(None, Some("out.ps")).unwrap_err();
|
||||
assert_eq!(e, "PostScript files can be opened but not written (see document.formats)");
|
||||
}
|
||||
|
||||
/// A program in the legacy `.ai` format with a template box and no art size (version 3) opens as a
|
||||
/// `.ai` or `.ait` on a US Letter artboard around the box's center, and as an EPS on its bounding
|
||||
/// box.
|
||||
#[test]
|
||||
fn a_legacy_ai_program_opens_on_its_artboard_and_an_eps_on_its_page() {
|
||||
let program = b"%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: a script\n%%BoundingBox: 10 10 30 30\n%AI3_TemplateBox: 100 100 100 100\n\
|
||||
%%EndComments\n%%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def /k {setcmykcolor} def\n\
|
||||
%%EndProlog\n%%BeginSetup\n%%EndSetup\n0 0 0 1 k\n10 10 m\n30 10 L\n30 30 L\n10 30 L\nf\n%%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n";
|
||||
for (name, size) in [("old.ai", (612.0, 792.0)), ("old.ait", (612.0, 792.0)), ("old.eps", (20.0, 20.0))] {
|
||||
let r = load(name, program).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(r.doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(size.0, size.1), "{name}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,10 +386,32 @@ fn replacing_a_clipped_psd_keeps_its_scale() {
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{r}");
|
||||
let (anchors, _, _) = clip_group(&s.doc().unwrap().doc, NodeId(r["ids"][0].as_u64().unwrap()));
|
||||
assert_eq!(anchors, [[200.0, 150.0], [300.0, 250.0], [100.0, 250.0]]);
|
||||
// Other art replacing the clip group takes its centre only, as before.
|
||||
// A plain image replacing the clip group keeps its scale too.
|
||||
let plain = vectorcraft_format::base64_encode(&red_psd(&[]));
|
||||
let r = s.execute("file.place", &json!({"name": "plain.psd", "dataBase64": plain, "replace": true})).unwrap();
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(100.0), Some(50.0)), "{r}");
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{r}");
|
||||
}
|
||||
|
||||
/// A plain image replacing a clipped PSD file takes the clipped image's scale and rotation.
|
||||
#[test]
|
||||
fn a_plain_image_replacing_a_clipped_psd_keeps_its_rotation() {
|
||||
let data = vectorcraft_format::base64_encode(&red_psd(&triangle_resources()));
|
||||
let plain = vectorcraft_format::base64_encode(&red_psd(&[]));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": data, "at": [200, 200]})).unwrap();
|
||||
let group = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 150})).unwrap();
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
let xf = |s: &Session, id: NodeId| match s.doc().unwrap().doc.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Image(im)) => im.xf.as_coeffs(),
|
||||
other => panic!("not an image: {other:?}"),
|
||||
};
|
||||
let (_, _, image) = clip_group(&s.doc().unwrap().doc, group);
|
||||
let before = xf(&s, image);
|
||||
let r = s.execute("file.place", &json!({"name": "plain.psd", "dataBase64": plain, "replace": true})).unwrap();
|
||||
let after = xf(&s, NodeId(r["ids"][0].as_u64().unwrap()));
|
||||
assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-9), "{after:?} != {before:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Freeform Point",
|
||||
[],
|
||||
None,
|
||||
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind; the points on a label's characters are not stored → {index}",
|
||||
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind; the points on a label's characters are not stored → {index}",
|
||||
has_doc,
|
||||
add_point
|
||||
),
|
||||
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Freeform Point",
|
||||
[],
|
||||
None,
|
||||
"{index? (default: the selected point), at?: [x,y] (document coordinates), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} move or recolour a point of the freeform gradient → {index}",
|
||||
"{index? (default: the selected point), at?: [x,y] (document coordinates), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} move or recolour a point of the freeform gradient → {index}",
|
||||
has_doc,
|
||||
set_point
|
||||
),
|
||||
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Freeform Point",
|
||||
[],
|
||||
None,
|
||||
"{index? (default: the selected point), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} remove a point (never the last one); lines through it close up around it and lines left with one point go → {index}",
|
||||
"{index? (default: the selected point), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} remove a point (never the last one); lines through it close up around it and lines left with one point go → {index}",
|
||||
has_doc,
|
||||
delete_point
|
||||
),
|
||||
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Freeform Line",
|
||||
[],
|
||||
None,
|
||||
"{points: [index, …] (at least two), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} join points with a smooth line through them, in order → {line}",
|
||||
"{points: [index, …] (at least two), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} join points with a smooth line through them, in order → {line}",
|
||||
has_doc,
|
||||
add_line
|
||||
),
|
||||
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Split Freeform Line",
|
||||
[],
|
||||
None,
|
||||
"{line: line index, segment: the segment from the line's point `segment` to the next, t?: 0..1 along it (default 0.5), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point on a line there (colour, opacity and spread between the segment's ends) and select it → {index}",
|
||||
"{line: line index, segment: the segment from the line's point `segment` to the next, t?: 0..1 along it (default 0.5), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} add a point on a line there (colour, opacity and spread between the segment's ends) and select it → {index}",
|
||||
has_doc,
|
||||
split_line
|
||||
),
|
||||
@@ -201,7 +201,9 @@ fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor
|
||||
first.get_or_insert(r);
|
||||
Ok(())
|
||||
};
|
||||
edit_items(s, &ids, item, cmd, "Gradient", !stroke, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, cmd)?;
|
||||
let shown_at = super::appearance::first_edited(s, item, of, &ids);
|
||||
edit_items(s, &ids, item, of, cmd, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -222,7 +224,9 @@ fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor
|
||||
})?;
|
||||
let r = first.ok_or_else(|| bad(cmd, NOT_FREEFORM))?;
|
||||
// The edited gradient is the last one used (`paint.lastGradient`).
|
||||
let shown = s.doc()?.doc.node(ids[0]).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten()));
|
||||
let shown = shown_at.and_then(|(id, item)| {
|
||||
s.doc().ok()?.doc.node(id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten()))
|
||||
});
|
||||
if let Some(p) = shown {
|
||||
s.remember_paint(&p);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without its placement, fitted to each label",
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.setFill; without stroke, the kind of the row edited), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without its placement, fitted to each label",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient Vector",
|
||||
[],
|
||||
None,
|
||||
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without the placement this command sets, fitted to each label",
|
||||
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without the placement this command sets, fitted to each label",
|
||||
has_doc,
|
||||
set_gradient_geom
|
||||
),
|
||||
@@ -580,7 +580,10 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(&for_default, &default_paint, &stored), None, &|_| true, model)
|
||||
.map_err(|e| bad(C, e))?;
|
||||
let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform);
|
||||
edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, C)?;
|
||||
let shown_at = super::appearance::first_edited(s, item, of, &ids);
|
||||
// The objects returned: with a design's art, those with a row equal to the art's.
|
||||
let edited = edit_items(s, &ids, item, of, C, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -616,9 +619,12 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.gradient_mode = m;
|
||||
}
|
||||
// The last gradient is the one the first object now shows (the defaults' without objects).
|
||||
let shown = match ids.first() {
|
||||
Some(id) => s.doc()?.doc.node(*id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten())),
|
||||
None => Some(new_default.clone()),
|
||||
let shown = match shown_at {
|
||||
Some((id, item)) => {
|
||||
s.doc()?.doc.node(id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten()))
|
||||
}
|
||||
None if ids.is_empty() => Some(new_default.clone()),
|
||||
None => None,
|
||||
};
|
||||
if let Some(shown) = shown {
|
||||
s.remember_paint(&shown);
|
||||
@@ -628,7 +634,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
s.paint.fill = new_default;
|
||||
}
|
||||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
Ok(json!({"ids": edited.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// A painted stroke on a type run without a weight gets 1 pt.
|
||||
@@ -766,7 +772,9 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let targets = super::appearance::drop_design_art(&s.doc()?.doc, targets);
|
||||
let model = super::paint::new_color_model(s, &Value::Null);
|
||||
edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, C)?;
|
||||
// The objects returned: with a design's art, those with a row equal to the art's.
|
||||
let targets = edit_items(s, &targets, item, of, C, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
|
||||
@@ -869,6 +869,188 @@ fn aligned_rows_of_a_graph_designs_art_and_a_different_stack_are_not_moved() {
|
||||
assert_eq!(paints, [art_was.items[1].paint().clone(), art_was.items[0].paint().clone(), art_was.items[1].paint().clone()]);
|
||||
}
|
||||
|
||||
/// An `item` given with `aligned: true` and the art of a graph design in a series first is a row of the art's stack in the
|
||||
/// paint and stroke commands: each other object edits the row equal to it, and a row the art does not have as a fill or
|
||||
/// stroke as edited, or that no other object has, is an error that leaves the document and its undo history as they are.
|
||||
#[test]
|
||||
fn aligned_paint_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
let green = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0));
|
||||
// The art's row 1 is the blue fill the mark has as row 0, and its row 2 the black stroke the mark has as row 1.
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
let black = rows(&s, mark).items[1].clone();
|
||||
s.edit("Stroke", |d, _| {
|
||||
d.node_mut(art).unwrap().appearance.items.push(black.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
|
||||
let fill = |s: &mut Session, cmd: &str, p: serde_json::Value| {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let now = rows(s, mark);
|
||||
assert_eq!(rows(s, art), art_was, "{cmd}");
|
||||
assert_ne!(now.items[0], mark_was.items[0], "{cmd} edits the mark's blue fill");
|
||||
assert_eq!(now.items[1], mark_was.items[1], "{cmd} leaves the mark's stroke");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(rows(s, mark), mark_was, "{cmd} is one undo step");
|
||||
now
|
||||
};
|
||||
let ids = [art.0, mark.0];
|
||||
let now = fill(&mut s, "paint.setFill", json!({"ids": ids, "item": 1, "aligned": true, "color": "#00ff00"}));
|
||||
assert_eq!(now.items[0].paint(), &green);
|
||||
let now = fill(&mut s, "paint.invert", json!({"ids": ids, "item": 1, "aligned": true}));
|
||||
assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the inverted color is the last color, not the art's");
|
||||
let now = fill(&mut s, "paint.lastColor", json!({"ids": ids, "item": 1, "aligned": true, "stroke": false}));
|
||||
assert_eq!(Some(s.last_solid), now.items[0].paint().color());
|
||||
// Without `aligned`, the art is left out and the item is a row of the mark.
|
||||
let now = fill(&mut s, "paint.invert", json!({"ids": ids, "item": 0}));
|
||||
assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the mark's inverted color is the last color, not the art's");
|
||||
s.last_solid = vectorcraft_color::Color::rgb(0.5, 0.5, 0.5);
|
||||
let now = fill(&mut s, "paint.complement", json!({"ids": ids, "item": 0}));
|
||||
assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the mark's complement is the last color, not the art's");
|
||||
let now = fill(&mut s, "paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"}));
|
||||
let Paint::Gradient(g) = now.items[0].paint() else { panic!("{:?}", now.items[0].paint()) };
|
||||
assert_eq!(&s.last_gradient, &**g, "the mark's new gradient is the last gradient");
|
||||
let now = fill(&mut s, "paint.lastGradient", json!({"ids": ids, "item": 1, "aligned": true, "stroke": false}));
|
||||
assert!(matches!(now.items[0].paint(), Paint::Gradient(_)));
|
||||
|
||||
let stroke = |s: &mut Session, cmd: &str, p: serde_json::Value| {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let now = rows(s, mark);
|
||||
assert_eq!(rows(s, art), art_was, "{cmd}");
|
||||
assert_eq!(now.items[0], mark_was.items[0], "{cmd} leaves the mark's fill");
|
||||
assert_ne!(now.items[1], mark_was.items[1], "{cmd} edits the mark's stroke");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(rows(s, mark), mark_was, "{cmd} is one undo step");
|
||||
now
|
||||
};
|
||||
let now = stroke(&mut s, "paint.setStroke", json!({"ids": ids, "item": 2, "aligned": true, "color": "#00ff00"}));
|
||||
assert_eq!(now.items[1].paint(), &green);
|
||||
let now = stroke(&mut s, "stroke.set", json!({"ids": ids, "item": 2, "aligned": true, "weight": 5}));
|
||||
assert_eq!(now.stroke_at(Some(1)).unwrap().width, 5.0);
|
||||
let now = stroke(&mut s, "stroke.setAdvanced", json!({"ids": ids, "item": 2, "aligned": true, "arrowScale": [200, 200]}));
|
||||
assert_eq!(now.stroke_at(Some(1)).unwrap().arrow_scale, (200.0, 200.0));
|
||||
// Without `stroke`, the kind of the art's row is edited.
|
||||
let now = stroke(&mut s, "paint.editGradient", json!({"ids": ids, "item": 2, "aligned": true, "kind": "linear"}));
|
||||
assert!(matches!(now.items[1].paint(), Paint::Gradient(_)));
|
||||
fill(&mut s, "paint.complement", json!({"ids": ids, "item": 1, "aligned": true, "stroke": false}));
|
||||
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
for (cmd, p, said) in [
|
||||
("stroke.set", json!({"ids": ids, "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"),
|
||||
("paint.setFill", json!({"ids": ids, "item": 2, "aligned": true, "color": "#00ff00"}), "appearance item 2 is not a fill"),
|
||||
("paint.setFill", json!({"ids": ids, "item": 0, "aligned": true, "color": "#00ff00"}), "no appearance item 0"),
|
||||
("paint.setFill", json!({"ids": ids, "item": 9, "aligned": true, "color": "#00ff00"}), "no appearance item 9"),
|
||||
// The art alone: no other object has the row.
|
||||
("paint.setFill", json!({"ids": [art.0], "item": 9, "aligned": true, "color": "#00ff00"}), "no appearance item 9"),
|
||||
("paint.setFill", json!({"ids": [art.0], "item": 1, "aligned": true, "color": "#00ff00"}), "no appearance item 1"),
|
||||
("stroke.set", json!({"ids": [art.0], "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"),
|
||||
("paint.editGradient", json!({"ids": [art.0], "item": 1, "aligned": true, "kind": "linear"}), "no appearance item 1"),
|
||||
// The weight steps take no row: they step each object's active or top stroke.
|
||||
("stroke.weightUp", json!({"ids": ids, "item": 2}), "unknown parameter `item`"),
|
||||
("stroke.weightUp", json!({"ids": ids, "aligned": true}), "unknown parameter `aligned`"),
|
||||
("stroke.weightDown", json!({"ids": ids, "item": 2}), "unknown parameter `item`"),
|
||||
("stroke.weightDown", json!({"ids": ids, "aligned": true}), "unknown parameter `aligned`"),
|
||||
] {
|
||||
let defaults = format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient));
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(said), "{cmd}: {e}");
|
||||
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was.clone()), "{cmd}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}");
|
||||
assert_eq!(format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient)), defaults, "{cmd}");
|
||||
}
|
||||
|
||||
// An object with no row equal to the art's is left out of what the commands report as edited.
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let rect = *s.doc().unwrap().selection.subjects().first().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [rect.0], "color": "#ff00ff"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [rect.0], "color": "#ff00ff"})).unwrap();
|
||||
let rect_was = rows(&s, rect);
|
||||
let ids = [art.0, mark.0, rect.0];
|
||||
for (cmd, p, key, said) in [
|
||||
("paint.setFill", json!({"ids": ids, "item": 1, "aligned": true, "color": "#00ff00"}), "objects", json!(1)),
|
||||
("stroke.set", json!({"ids": ids, "item": 2, "aligned": true, "weight": 5}), "objects", json!(1)),
|
||||
("paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"}), "ids", json!([mark.0])),
|
||||
("paint.setGradientGeom", json!({"ids": ids, "item": 1, "aligned": true, "start": [0, 0], "end": [10, 40]}), "ids", json!([mark.0])),
|
||||
] {
|
||||
if cmd == "paint.setGradientGeom" {
|
||||
s.execute("paint.editGradient", &json!({"ids": [mark.0], "item": 0, "kind": "linear"})).unwrap();
|
||||
let gradient = rows(&s, mark).items[0].clone();
|
||||
s.edit("Gradient", |d, _| {
|
||||
d.node_mut(art).unwrap().appearance.items[1] = gradient.clone();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let r = s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(r[key], said, "{cmd}: {r}");
|
||||
assert_eq!(rows(&s, rect), rect_was, "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
// A gradient spanning the selection spans only the objects that take it: a distant object without an equal row does
|
||||
// not move it.
|
||||
s.execute("shape.rectangle", &json!({"x": 5000, "y": 5000, "width": 10, "height": 10})).unwrap();
|
||||
let far = *s.doc().unwrap().selection.subjects().first().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [far.0], "color": "#ff00ff"})).unwrap();
|
||||
let far_was = rows(&s, far);
|
||||
let gradient = json!({"stops": [{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]});
|
||||
let mut placed = Vec::new();
|
||||
for ids in [vec![art.0, mark.0], vec![art.0, mark.0, far.0]] {
|
||||
s.execute("paint.setFill", &json!({"ids": ids, "item": 1, "aligned": true, "gradient": gradient, "span": "selection"})).unwrap();
|
||||
placed.push(rows(&s, mark).items[0].clone());
|
||||
assert_eq!(rows(&s, far), far_was);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert!(matches!(placed[0].paint(), Paint::Gradient(_)));
|
||||
assert_eq!(placed[1], placed[0]);
|
||||
}
|
||||
|
||||
/// The gradient and freeform gradient commands read an `item` given with `aligned: true` and the art of a graph design in
|
||||
/// a series first as a row of the art's stack, as the paint commands do.
|
||||
#[test]
|
||||
fn aligned_gradient_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
for kind in ["linear", "freeform"] {
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
// The mark's fill and the art's equal row become the same gradient.
|
||||
s.execute("paint.editGradient", &json!({"ids": [mark.0], "item": 0, "kind": kind})).unwrap();
|
||||
let gradient = rows(&s, mark).items[0].clone();
|
||||
// A yellow fill above the mark's gradient is the mark's row 1, as the gradient is the art's row 1.
|
||||
let mut yellow = gradient.clone();
|
||||
*yellow.paint_mut() = Paint::solid(vectorcraft_color::Color::rgb(1.0, 1.0, 0.0));
|
||||
s.edit("Gradient", |d, _| {
|
||||
d.node_mut(art).unwrap().appearance.items[1] = gradient.clone();
|
||||
d.node_mut(mark).unwrap().appearance.items.insert(1, yellow.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let ids = [art.0, mark.0];
|
||||
let edits = if kind == "linear" {
|
||||
vec![
|
||||
("paint.setGradientGeom", json!({"ids": ids, "item": 1, "aligned": true, "start": [0, 0], "end": [10, 40]})),
|
||||
("paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "radial"})),
|
||||
]
|
||||
} else {
|
||||
vec![("paint.freeform.addPoint", json!({"ids": ids, "item": 1, "aligned": true, "at": [5, 5]}))]
|
||||
};
|
||||
for (cmd, p) in edits {
|
||||
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let now = rows(&s, mark);
|
||||
assert_eq!(rows(&s, art), art_was, "{cmd}");
|
||||
assert_ne!(now.items[0], mark_was.items[0], "{cmd} edits the mark's gradient fill");
|
||||
assert_eq!(now.items[1..], mark_was.items[1..], "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// The edited gradient is the last gradient (paint.setGradientGeom does not set it).
|
||||
if cmd != "paint.setGradientGeom" {
|
||||
let Paint::Gradient(g) = now.items[0].paint() else { panic!("{cmd}") };
|
||||
assert_eq!(&s.last_gradient, &**g, "{cmd}: the mark's edited gradient is the last gradient");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Contents row of a graph design's copy and of a group whose stack differs is not moved with `aligned: true`, as
|
||||
/// the rows are not.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Window › Info (`info.readout`): the selection's size and the active tool's readout, as the Info
|
||||
//! panel shows them.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::{CommandSpec, Result, Session, always, cmd};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
query "info.readout",
|
||||
"Info",
|
||||
[],
|
||||
None,
|
||||
"{} → {tool: the active tool's id, width?, height?: the sides of the selection's bounding box in pt (the box around the anchors selected with Direct Selection, when there are some), measured as prefs usePreviewBounds says; left out with nothing selected, readout?: what the Info panel shows for the tool in use: {kind: \"drag\", dx, dy, distance, angle} while the Selection, Direct Selection or Group Selection tool moves the selection or the Gradient tool draws a gradient vector on the art; {kind: \"scale\", width, height} (percent) during a Scale tool drag; {kind: \"rotate\", center: [x, y], angle: deg counter-clockwise, as object.rotate takes it} and {kind: \"reflect\", center, angle: the axis in deg counter-clockwise from horizontal, as object.reflect takes it} during a Rotate or Reflect tool drag; {kind: \"shear\", center, axis: 0 for a horizontal shear and 90 for a vertical one, angle: deg, as object.shear takes it} during a Shear tool drag; {kind: \"brush\", brush: name|null} while the Paintbrush is the tool; left out for any other tool and between drags. Lengths are in pt, a drag's angle is in deg counter-clockwise from the x axis, and center is the center of the selection's bounding box as the drag has carried it, in document coordinates}",
|
||||
always,
|
||||
readout
|
||||
)]
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The sides of the selection's bounding box as the Info panel shows them: of the box around
|
||||
/// the anchors selected with Direct Selection when there are some, else of the bounding box
|
||||
/// rotated with rotated objects ([`Session::transform_box`]), measured as Use Preview Bounds
|
||||
/// says. None with nothing selected.
|
||||
pub fn info_size(&self) -> Option<(f64, f64)> {
|
||||
let st = self.active()?;
|
||||
let anchors = effects::preview::anchor_bounds_with(&st.selection, &st.doc, self.bounds_measure());
|
||||
let r = anchors.or_else(|| self.transform_box(&st.selection.objects).map(|b| b.rect))?;
|
||||
Some((r.width(), r.height()))
|
||||
}
|
||||
}
|
||||
|
||||
fn readout(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut out = json!({ "tool": s.tool_id() });
|
||||
if let Some((w, h)) = s.info_size() {
|
||||
out["width"] = json!(w);
|
||||
out["height"] = json!(h);
|
||||
}
|
||||
if let Some(r) = s.tool_readout() {
|
||||
out["readout"] = r.to_json();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use crate::{Session, ViewInfo};
|
||||
|
||||
fn view() -> ViewInfo {
|
||||
ViewInfo { smart_guides: false, snap_to_point: false, ..Default::default() }
|
||||
}
|
||||
|
||||
/// A 100 by 60 rectangle at (50, 50), selected, with a 10 pt stroke.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 60})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({ "ids": [id] })).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 10})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn tool(s: &mut Session, id: &str) {
|
||||
s.select_tool(id, view()).unwrap();
|
||||
}
|
||||
|
||||
fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
|
||||
s.pointer(&PointerEvent::new(kind, x, y), view()).unwrap();
|
||||
}
|
||||
|
||||
fn info(s: &mut Session) -> serde_json::Value {
|
||||
s.execute("info.readout", &json!({})).unwrap()
|
||||
}
|
||||
|
||||
fn near(v: &serde_json::Value, want: f64) -> bool {
|
||||
v.as_f64().is_some_and(|v| (v - want).abs() < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_size_follows_use_preview_bounds() {
|
||||
let mut s = session();
|
||||
let i = info(&mut s);
|
||||
assert!(near(&i["width"], 100.0) && near(&i["height"], 60.0), "{i}");
|
||||
assert!(i.get("readout").is_none(), "{i}");
|
||||
s.execute("prefs.set", &json!({"key": "usePreviewBounds", "value": true})).unwrap();
|
||||
let i = info(&mut s);
|
||||
assert!(near(&i["width"], 110.0) && near(&i["height"], 70.0), "{i}");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let i = info(&mut s);
|
||||
assert!(i.get("width").is_none() && i.get("height").is_none(), "{i}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_reads_how_far_it_has_gone_until_the_release() {
|
||||
let mut s = session();
|
||||
tool(&mut s, "selection");
|
||||
pointer(&mut s, PointerKind::Down, 100.0, 80.0);
|
||||
assert!(info(&mut s).get("readout").is_none());
|
||||
pointer(&mut s, PointerKind::Drag, 130.0, 40.0);
|
||||
let i = info(&mut s);
|
||||
let r = &i["readout"];
|
||||
assert_eq!(r["kind"], "drag", "{i}");
|
||||
assert!(near(&r["dx"], 30.0) && near(&r["dy"], -40.0) && near(&r["distance"], 50.0), "{i}");
|
||||
assert!(near(&r["angle"], 53.13010235415598), "{i}");
|
||||
pointer(&mut s, PointerKind::Up, 130.0, 40.0);
|
||||
let i = info(&mut s);
|
||||
assert!(i.get("readout").is_none(), "{i}");
|
||||
assert!(near(&i["width"], 100.0), "{i}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_transform_tool_reads_its_drag() {
|
||||
for (id, check) in [
|
||||
("scale", (|r| r["kind"] == "scale" && near(&r["width"], 200.0) && near(&r["height"], 200.0)) as fn(&serde_json::Value) -> bool),
|
||||
("rotate", |r| r["kind"] == "rotate" && near(&r["center"][0], 100.0) && near(&r["center"][1], 80.0) && near(&r["angle"], -90.0)),
|
||||
("reflect", |r| r["kind"] == "reflect" && near(&r["center"][0], 100.0) && near(&r["angle"], 0.0)),
|
||||
("shear", |r| {
|
||||
r["kind"] == "shear"
|
||||
&& near(&r["center"][0], 100.0)
|
||||
&& near(&r["center"][1], 80.0)
|
||||
&& near(&r["axis"], 0.0)
|
||||
&& near(&r["angle"], 45.0)
|
||||
}),
|
||||
] {
|
||||
let mut s = session();
|
||||
tool(&mut s, id);
|
||||
// The reference point is the box's center (100, 80).
|
||||
let from = if id == "shear" { (100.0, 60.0) } else { (120.0, 100.0) };
|
||||
pointer(&mut s, PointerKind::Down, from.0, from.1);
|
||||
let to = match id {
|
||||
"scale" => (140.0, 120.0),
|
||||
"rotate" => (80.0, 100.0),
|
||||
"reflect" => (160.0, 80.0),
|
||||
_ => (120.0, 60.0),
|
||||
};
|
||||
pointer(&mut s, PointerKind::Drag, to.0, to.1);
|
||||
let i = info(&mut s);
|
||||
assert_eq!(i["tool"], id);
|
||||
assert!(check(&i["readout"]), "{id}: {i}");
|
||||
pointer(&mut s, PointerKind::Up, to.0, to.1);
|
||||
assert!(info(&mut s).get("readout").is_none(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_paintbrush_reads_its_brush() {
|
||||
let mut s = session();
|
||||
tool(&mut s, "paintbrush");
|
||||
let i = info(&mut s);
|
||||
assert_eq!(i["readout"], json!({"kind": "brush", "brush": null}), "{i}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parameter_is_refused() {
|
||||
let mut s = session();
|
||||
let e = s.execute("info.readout", &json!({"tool": "scale"})).unwrap_err().to_string();
|
||||
assert!(e.contains("tool"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -1298,7 +1298,112 @@ pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
|
||||
/// alone in a reply to the caller asking now? Only a path [`path_shown`] does not allow; a URL is
|
||||
/// always shown as it is.
|
||||
pub(crate) fn script_hidden(f: &str, doc_path: Option<&str>) -> bool {
|
||||
!path_shown(f, doc_path) && !matches!(fileio::document_path(f, doc_path), Err(craft_docpath::Refused::Url))
|
||||
file_hidden(f, doc_path)
|
||||
}
|
||||
|
||||
/// Is the file `path` (a link of the document saved at `doc_path`) one the caller asking now may
|
||||
/// not be shown? Only a path [`path_shown`] does not allow; a URL is not a file and is shown as it is.
|
||||
pub(crate) fn file_hidden(path: &str, doc_path: Option<&str>) -> bool {
|
||||
!path_shown(path, doc_path) && !matches!(fileio::document_path(path, doc_path), Err(craft_docpath::Refused::Url))
|
||||
}
|
||||
|
||||
/// Does an SVG of `d` (a document whose links are looked for from `base`) written for the caller
|
||||
/// asking now name the file of a linked image that [`file_hidden`] keeps from it? An `<image>`
|
||||
/// names its image's file with SVG Options › Images set to Link (`link`), and with Embed when the
|
||||
/// document holds no pixels for the image. An image with a solid colorize paint whose pixels are
|
||||
/// gray or missing names none: the SVG writes gray pixels colorized and leaves out an image
|
||||
/// without pixels. The images of the art the SVG writes (opacity masks included) and of the
|
||||
/// symbols, patterns and brushes that art uses, as Copy follows them, are checked: the SVG writes
|
||||
/// no other resource. Hidden layers and objects are written only with
|
||||
/// SVG Options › hidden layers kept (`hidden`), and template layers never ([`svg_written`]).
|
||||
/// Always `false` without read roots in force.
|
||||
pub(crate) fn svg_names_hidden_image(d: &Document, base: LinkBase, link: bool, hidden: bool) -> bool {
|
||||
if crate::file_access::current().is_none() || !names_hidden_image(d, base, link) {
|
||||
return false;
|
||||
}
|
||||
let art = d.layers.iter().filter_map(|l| svg_written(l, hidden)).collect();
|
||||
names_hidden_image(&super::clipboard::Clipboard::gather(d, art).to_document(), base, link)
|
||||
}
|
||||
|
||||
/// `n` as the SVG writer writes it, or `None` when it writes none of it: a hidden layer or object
|
||||
/// is left out unless `hidden`, a template layer's art always, and a disabled opacity mask's art.
|
||||
/// Only layers and groups lose members; other objects keep theirs as they are.
|
||||
fn svg_written(n: &Node, hidden: bool) -> Option<Node> {
|
||||
if !(n.visible || hidden) {
|
||||
return None;
|
||||
}
|
||||
let mut n = n.clone();
|
||||
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled).and_then(|m| {
|
||||
let art = svg_written(&m.art, hidden)?;
|
||||
Some(Box::new(vectorcraft_doc::OpacityMask { art: std::sync::Arc::new(art), ..m.clone() }))
|
||||
});
|
||||
if n.extras().mask.is_some() {
|
||||
n.extras_mut().mask = mask;
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Layer { template: true, children, .. } => children.clear(),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
*children = children.iter().filter_map(|c| svg_written(c, hidden)).map(std::sync::Arc::new).collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// [`svg_names_hidden_image`] for every image of `d`: in the layers, symbols, graph designs,
|
||||
/// patterns, opacity masks and the art of the stored brushes, used or not.
|
||||
fn names_hidden_image(d: &Document, base: LinkBase, link: bool) -> bool {
|
||||
let d = with_brush_art(d);
|
||||
let mut hidden = false;
|
||||
d.visit_images(|n, im| {
|
||||
if hidden {
|
||||
return;
|
||||
}
|
||||
let shown = d.shown_image(n, im);
|
||||
let Some(l) = &shown.link else { return };
|
||||
let blob = d.image_blob(&shown).filter(|b| !b.bytes.is_empty());
|
||||
// The SVG writes an image with a solid colorize paint as colorized pixels when they are
|
||||
// gray, and leaves it out when the document holds none of them.
|
||||
if matches!(n.extras().colorize.as_deref(), Some(vectorcraft_color::Paint::Solid { .. })) && blob.is_none_or(|b| b.gray_source().is_some()) {
|
||||
return;
|
||||
}
|
||||
let named = link || blob.is_none();
|
||||
hidden = named && file_hidden(&l.path, base.doc);
|
||||
});
|
||||
hidden
|
||||
}
|
||||
|
||||
/// Does the native document an SVG of `d` (a document whose links are looked for from `base`)
|
||||
/// carries with Preserve Editing Capabilities name a file that the caller asking now may not be
|
||||
/// shown: the file of a linked image or placed document ([`file_hidden`]), the relative path kept
|
||||
/// of one, which is followed from the file the document was read from and from the document's own
|
||||
/// folder, or a data set's image? Links in the layers, symbols, graph designs, patterns, the pattern
|
||||
/// being edited as it was before, opacity masks and the art of the stored brushes are all checked,
|
||||
/// as [`with_hidden_paths_named`] checks them. A stored brush library that is not a list, or holds
|
||||
/// an entry that does not read as a brush, counts as naming one: the native document carries it as
|
||||
/// it is, and its links cannot be checked. Always `false` without read roots in force.
|
||||
pub(crate) fn names_hidden_file(d: &Document, base: LinkBase) -> bool {
|
||||
if crate::file_access::current().is_none() {
|
||||
return false;
|
||||
}
|
||||
let unread = |list: &Vec<Value>| list.iter().any(|raw| vectorcraft_brush::Brush::from_json(raw.clone()).is_err());
|
||||
if d.unknown.get(vectorcraft_brush::DOC_KEY).is_some_and(|v| v.as_array().is_none_or(unread)) {
|
||||
return true;
|
||||
}
|
||||
let relative_hidden = |r: &str| [base.relative, base.doc].into_iter().any(|from| !path_shown(r, from));
|
||||
let hidden = |l: &LinkInfo| file_hidden(&l.path, base.doc) || l.relative.as_deref().is_some_and(relative_hidden);
|
||||
let image_hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if file_hidden(path, base.doc));
|
||||
if d.variables.datasets.iter().any(|set| set.values.values().any(image_hidden)) {
|
||||
return true;
|
||||
}
|
||||
let mut d = with_brush_art(d);
|
||||
if let Some(original) = d.pattern_edit.as_ref().and_then(|e| e.original.clone()) {
|
||||
d.to_mut().patterns.push(original);
|
||||
}
|
||||
let mut found = false;
|
||||
d.visit_images(|_, im| found |= im.link.as_ref().is_some_and(hidden));
|
||||
d.visit_placed(|_, p| found |= hidden(&p.link));
|
||||
found
|
||||
}
|
||||
|
||||
/// The linked JavaScript file `f` of the open document `st` as a reply to the caller asking now
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke",
|
||||
[],
|
||||
None,
|
||||
"{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, as paint.setFill",
|
||||
"{…as paint.setFill, item?: the stroke item to set, aligned?: bool (as paint.setFill)} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, as paint.setFill",
|
||||
has_doc,
|
||||
|s, p| { set_paint(s, p, false) }
|
||||
),
|
||||
@@ -96,7 +96,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Invert",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke, and on a graph label the new color of its characters and rows for every label of its kind (docs/mcp.md); no undo step when no color changes → {changed}",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, aligned?: bool (as paint.setFill), target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke, and on a graph label the new color of its characters and rows for every label of its kind (docs/mcp.md); no undo step when no color changes → {changed}",
|
||||
has_doc,
|
||||
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
|
||||
),
|
||||
@@ -105,7 +105,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Complement",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default; no undo step when no color changes → {changed}",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, aligned?: bool (as paint.setFill), target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default; no undo step when no color changes → {changed}",
|
||||
has_doc,
|
||||
|s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model())
|
||||
),
|
||||
@@ -114,7 +114,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Apply Last Color",
|
||||
[],
|
||||
Some(","),
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, aligned?: bool (as paint.setFill), target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let paint = Paint::solid(s.last_solid);
|
||||
@@ -126,7 +126,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Apply Last Gradient",
|
||||
[],
|
||||
Some("."),
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, aligned?: bool (as paint.setFill), target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone())));
|
||||
@@ -553,7 +553,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, fill);
|
||||
let selected = item.targets(s, p)?;
|
||||
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
|
||||
let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint };
|
||||
let of = super::appearance::reference_of(s, p, item, cmd)?;
|
||||
let paint = if span {
|
||||
// With a design's art, the gradient spans only the objects with a row equal to the art's, which take it.
|
||||
let spanned: Vec<NodeId> = ids.iter().zip(item.per_target_of(s, of, &ids)).filter_map(|(id, it)| it.map(|_| *id)).collect();
|
||||
placed_across(&s.doc()?.doc, &spanned, paint, p, fill)
|
||||
} else {
|
||||
paint
|
||||
};
|
||||
// The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso: a solid
|
||||
// fill recolors just those points of their meshes (the proxies show them: `fill_mesh_points`).
|
||||
let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] };
|
||||
@@ -570,10 +577,12 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
// The type objects whose characters took the paint (no row targeted): only those change what a graph's labels keep
|
||||
// for their characters; a row edit is kept with the label's rows.
|
||||
let characters = std::cell::RefCell::new(Vec::new());
|
||||
edit_items_then(
|
||||
// The objects counted: with a design's art, those with a row equal to the art's.
|
||||
let ids = edit_items_then(
|
||||
s,
|
||||
&ids,
|
||||
item,
|
||||
of,
|
||||
cmd,
|
||||
label,
|
||||
fill,
|
||||
@@ -1070,30 +1079,32 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(
|
||||
return Ok(json!({ "changed": changed as usize }));
|
||||
}
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, !stroke);
|
||||
let (r, shown_item, edited) = match item {
|
||||
ItemTarget::Item { index, .. } => {
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?);
|
||||
let (r, shown_at) = match item {
|
||||
ItemTarget::Item { .. } => {
|
||||
let targets = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?);
|
||||
let of = super::appearance::reference_of(s, p, item, cmd)?;
|
||||
// The row shown is the one edited in the first object that has it: an aligned row maps to each object's equal row.
|
||||
let shown_at = super::appearance::first_edited(s, item, of, &targets).and_then(|(id, it)| match it {
|
||||
ItemTarget::Item { index, .. } => Some((id, Some(index))),
|
||||
ItemTarget::Top => None,
|
||||
});
|
||||
let mut changed = 0;
|
||||
edit_items(s, &ids, item, cmd, label, !stroke, |n, i| {
|
||||
edit_items(s, &targets, item, of, cmd, label, !stroke, |n, i| {
|
||||
let paint =
|
||||
if stroke { n.appearance.stroke_at_mut(i).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(i).map(|l| &mut l.paint) };
|
||||
changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize;
|
||||
Ok(())
|
||||
})?;
|
||||
(json!({ "changed": changed }), Some(index), Some(ids))
|
||||
(json!({ "changed": changed }), shown_at)
|
||||
}
|
||||
// The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles.
|
||||
ItemTarget::Top => {
|
||||
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
|
||||
(super::colorcmds::recolor(s, &q, cmd, label, f)?, None, None)
|
||||
(super::colorcmds::recolor(s, &q, cmd, label, f)?, leaf_targets(s, &ids)?.first().map(|id| (*id, None)))
|
||||
}
|
||||
};
|
||||
// The paint remembered is one the edit recolored: a graph design's art that the item edit leaves out gives none.
|
||||
let shown_ids = match edited {
|
||||
Some(ids) => ids,
|
||||
None => leaf_targets(s, &ids)?,
|
||||
};
|
||||
if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) {
|
||||
if let Some(shown) = shown_at.and_then(|(id, at)| s.doc().ok()?.doc.node(id).map(|n| proxy_paint(n, stroke, at))) {
|
||||
s.remember_paint(&shown);
|
||||
}
|
||||
Ok(r)
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file or PSD/PSB file placed in a clip group, which the Links commands take}. A PSD or PSB file that names a clipping path places in a clip group of that path (even-odd), as does its merged image in document.open. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at), showImportOptions?: true (in the app, a text file without text, a DXF drawing without dxf and a PDF with several pages or a password and no page ask for those options first; false places them with the options last confirmed, a PDF on page 1 (or page), and asks only an encrypted PDF's password)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file or PSD/PSB file placed in a clip group, which the Links commands take}. A PSD or PSB file that names a clipping path places in a clip group of that path (even-odd), as does its merged image in document.open. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
@@ -495,14 +495,14 @@ fn at_param(p: &Value) -> Result<Option<Point>> {
|
||||
|
||||
/// The transform that puts new art with the natural box `natural` where `old` is: an image's scale
|
||||
/// and rotation relative to its 100% size (its resolution's) and its centre are kept (with
|
||||
/// `image_xf`), also for an image alone in a clip group when the new art is an image clipped by
|
||||
/// its file's clipping path (`clipped`); other objects keep their centre.
|
||||
fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool, clipped: bool) -> Affine {
|
||||
/// `image_xf`), also for an image alone in a clip group when the new art is an image, clipped by
|
||||
/// its file's clipping path or not (`new_image`); other objects keep their centre.
|
||||
fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool, new_image: bool) -> Affine {
|
||||
// An image alone in a clip group (as a PSD or PSB file's clipping path places it,
|
||||
// [`fileio::file_clipped`]) is measured as the image when the new art brings its own clip
|
||||
// group; a DCS file's plates are not.
|
||||
// [`fileio::file_clipped`]) is measured as the image when the new art is an image; a DCS
|
||||
// file's plates are not.
|
||||
let image = match &old.kind {
|
||||
NodeKind::Group { children, clip: true } if clipped => match &children[..] {
|
||||
NodeKind::Group { children, clip: true } if new_image => match &children[..] {
|
||||
[path, image] if matches!(path.kind, NodeKind::Path { clipping: true, .. }) => match &image.kind {
|
||||
NodeKind::Image(im) if d.images.get(&im.key).is_none_or(|b| b.separated().is_none()) => image,
|
||||
_ => old,
|
||||
@@ -681,7 +681,7 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result<Value> {
|
||||
}
|
||||
let natural = loaded.natural;
|
||||
let m = match (old_node, rect) {
|
||||
(Some(o), _) => replace_xf(&st.doc, o, natural, !is_text, matches!(loaded.art, Art::Image(_, Some(_)))),
|
||||
(Some(o), _) => replace_xf(&st.doc, o, natural, !is_text, matches!(loaded.art, Art::Image(..))),
|
||||
(None, Some(r)) => fit(natural, r),
|
||||
// Its artboard's bottom-left corner on the target's.
|
||||
(None, None) if let (Some(src), Some(to)) = (loaded.board, target) => Affine::translate((to.x0 - src.x0, to.y1 - src.y1)),
|
||||
|
||||
@@ -540,6 +540,7 @@ struct Saved {
|
||||
recent_colors: Vec<vectorcraft_color::Color>,
|
||||
recent_urls: Vec<String>,
|
||||
global_edit_options: super::globaledit::Options,
|
||||
select_similar: &'static str,
|
||||
envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>,
|
||||
}
|
||||
|
||||
@@ -564,6 +565,7 @@ impl Saved {
|
||||
recent_colors: s.recent_colors.clone(),
|
||||
recent_urls: s.recent_urls.clone(),
|
||||
global_edit_options: s.global_edit_options.clone(),
|
||||
select_similar: s.select_similar,
|
||||
envelope_defaults: s.envelope_defaults,
|
||||
}
|
||||
}
|
||||
@@ -587,6 +589,7 @@ impl Saved {
|
||||
s.recent_colors = self.recent_colors;
|
||||
s.recent_urls = self.recent_urls;
|
||||
s.global_edit_options = self.global_edit_options;
|
||||
s.select_similar = self.select_similar;
|
||||
s.envelope_defaults = self.envelope_defaults;
|
||||
s.pending_paint = None;
|
||||
}
|
||||
|
||||
@@ -206,6 +206,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"imageTrace.makeAndExpand",
|
||||
"imageTrace.release",
|
||||
"imageTrace.setView",
|
||||
"info.readout",
|
||||
"layer.clippingMask.toggle",
|
||||
"layer.collectInNew",
|
||||
"layer.delete",
|
||||
@@ -536,6 +537,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"select.same.textStrokeColor",
|
||||
"select.segments",
|
||||
"select.set",
|
||||
"select.similar",
|
||||
"select.storedPath",
|
||||
"select.toggle",
|
||||
"select.update",
|
||||
|
||||
@@ -225,9 +225,69 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
same_attribute
|
||||
),
|
||||
cmd!(
|
||||
"select.similar",
|
||||
"Select Similar Objects",
|
||||
[],
|
||||
None,
|
||||
"{by?: \"all\"|\"fillColor\"|\"fillAndStroke\"|\"strokeColor\"|\"strokeWeight\"|\"opacity\"|\"blendingMode\"|\"appearance\"|\"graphicStyle\"|\"shapeType\"|\"symbolInstance\"} the Control bar's Select Similar Objects button and its menu: selects the objects like the first selected object by `by`, running the select.same command of that name; `all` selects the objects with its fill and stroke colours, stroke weight, opacity and blending mode (tints, compound paths and Live Paint faces as select.same.fillColor). A `by` given is kept for the session once the selection is made; without it, the kept one (`all` until one is given). Any other `by` is an error that keeps the selection and the kept attribute → {count, by}",
|
||||
has_selection,
|
||||
similar
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The attributes Select Similar Objects (`select.similar`) selects by, in its menu's order, each
|
||||
/// with the Select › Same command it runs; `all` compares the fill and stroke colours, the stroke
|
||||
/// weight, the opacity and the blending mode at once, with no command of its own.
|
||||
pub const SIMILAR: [(&str, Option<&str>); 11] = [
|
||||
("all", None),
|
||||
("fillColor", Some("select.same.fillColor")),
|
||||
("fillAndStroke", Some("select.same.fillAndStroke")),
|
||||
("strokeColor", Some("select.same.strokeColor")),
|
||||
("strokeWeight", Some("select.same.strokeWeight")),
|
||||
("opacity", Some("select.same.opacity")),
|
||||
("blendingMode", Some("select.same.blendingMode")),
|
||||
("appearance", Some("select.same.appearance")),
|
||||
("graphicStyle", Some("select.same.graphicStyle")),
|
||||
("shapeType", Some("select.same.shapeType")),
|
||||
("symbolInstance", Some("select.same.symbolInstance")),
|
||||
];
|
||||
|
||||
fn similar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.similar";
|
||||
let by = match text_param(p, "by", C)? {
|
||||
Some(name) => *SIMILAR.iter().find(|(n, _)| *n == name).ok_or_else(|| {
|
||||
let names: Vec<&str> = SIMILAR.iter().map(|(n, _)| *n).collect();
|
||||
bad(C, format!("unknown `by` \"{name}\": expected one of {}", names.join(", ")))
|
||||
})?,
|
||||
None => *SIMILAR.iter().find(|(n, _)| *n == s.select_similar).unwrap_or(&SIMILAR[0]),
|
||||
};
|
||||
let mut r = match by.1 {
|
||||
Some(same_cmd) => s.execute(same_cmd, &json!({}))?,
|
||||
None => {
|
||||
let tint = s.prefs.select_same_tint_percent;
|
||||
let r = same_fill(s, C, |a, b| {
|
||||
same_paint(&fill_of(a), &fill_of(b), tint)
|
||||
&& same_paint(&stroke_of(a).0, &stroke_of(b).0, tint)
|
||||
&& (stroke_weight(a) - stroke_weight(b)).abs() < 1e-9
|
||||
&& (a.opacity - b.opacity).abs() < 1e-6
|
||||
&& a.blend == b.blend
|
||||
})?;
|
||||
// Select › Reselect repeats `all`, whichever attribute is kept by then.
|
||||
if let Some(last) = s.doc_mut()?.last_selection_cmd.as_mut() {
|
||||
last.1 = json!({ "by": by.0 });
|
||||
}
|
||||
r
|
||||
}
|
||||
};
|
||||
s.select_similar = by.0;
|
||||
// A recorded action or the journal repeats the attribute used, not the one kept by then.
|
||||
s.note_journal("by", json!(by.0));
|
||||
r["by"] = json!(by.0);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Selectable objects: children of visible, unlocked layers (or of the isolation container).
|
||||
/// The objects Select All takes: those of the isolated container, else of every layer (looking
|
||||
/// through sublayers, which are not objects). In isolation mode there are none while the isolated
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters the stroke options they take → {objects: how many objects took the stroke options}",
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters the stroke options they take → {objects: how many objects took the stroke options}",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
@@ -49,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
[],
|
||||
None,
|
||||
"{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, item?: stroke item index|null, ids?|id?, target?: \"object\"|\"contents\"} Stroke panel extras (with nothing selected, all but brush set up the next object drawn)",
|
||||
"{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, item?: stroke item index|null, aligned?: bool (as stroke.set), ids?|id?, target?: \"object\"|\"contents\"} Stroke panel extras (with nothing selected, all but brush set up the next object drawn)",
|
||||
has_doc,
|
||||
stroke_advanced
|
||||
),
|
||||
@@ -303,7 +303,9 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.profile = pr.clone();
|
||||
}
|
||||
};
|
||||
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, C)?;
|
||||
// The objects counted: with a design's art, those with a row equal to the art's.
|
||||
let ids = edit_items(s, &ids, item, of, C, "Stroke", false, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -372,7 +374,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
|
||||
}
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
|
||||
let label = if up { "Stroke Weight Up" } else { "Stroke Weight Down" };
|
||||
edit_items(s, &ids, item, c, label, false, |n, index| {
|
||||
edit_items(s, &ids, item, None, c, label, false, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -535,7 +537,8 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
arrows_and_profile(st);
|
||||
}
|
||||
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, C)?;
|
||||
edit_items(s, &ids, item, of, C, "Stroke", false, |n, index| {
|
||||
let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) };
|
||||
arrows_and_profile(st);
|
||||
if let Some(b) = &brush {
|
||||
|
||||
@@ -344,6 +344,9 @@ pub struct Session {
|
||||
/// The Global Edit Options Start Global Edit matches with (`select.globalEditOptions`); not
|
||||
/// saved.
|
||||
pub global_edit_options: cmd::globaledit::Options,
|
||||
/// The attribute Select Similar Objects selects by (`select.similar`), one of
|
||||
/// [`cmd::SIMILAR`]; not saved.
|
||||
pub select_similar: &'static str,
|
||||
/// Set while [`Session::tool_typed_text`] passes keyboard input to the active tool
|
||||
/// ([`vectorcraft_tools::ToolContext::typed_from_keyboard`]).
|
||||
keyboard_text: bool,
|
||||
|
||||
@@ -432,7 +432,7 @@ struct World {
|
||||
}
|
||||
|
||||
/// The choices outside the documents that a script's commands can change: the preferences, the
|
||||
/// global edit options, the recent URLs, the chosen gradient stop, freeform point and appearance
|
||||
/// global edit options, the Select Similar Objects attribute, the recent URLs, the chosen gradient stop, freeform point and appearance
|
||||
/// item, the Eyedropper's sample, the envelope defaults and the Caps Lock state.
|
||||
#[derive(Clone)]
|
||||
struct Choices {
|
||||
@@ -440,6 +440,7 @@ struct Choices {
|
||||
caps_lock: bool,
|
||||
eyedropper_sample: Option<Arc<vectorcraft_doc::Node>>,
|
||||
global_edit_options: cmd::globaledit::Options,
|
||||
select_similar: &'static str,
|
||||
recent_urls: Vec<String>,
|
||||
gradient_stop: Option<(usize, cmd::gradient::StopOwner)>,
|
||||
freeform_point: Option<(usize, cmd::gradient::StopOwner)>,
|
||||
@@ -454,6 +455,7 @@ impl Choices {
|
||||
caps_lock: s.caps_lock,
|
||||
eyedropper_sample: s.eyedropper_sample.clone(),
|
||||
global_edit_options: s.global_edit_options.clone(),
|
||||
select_similar: s.select_similar,
|
||||
recent_urls: s.recent_urls.clone(),
|
||||
gradient_stop: s.gradient_stop,
|
||||
freeform_point: s.freeform_point,
|
||||
@@ -467,6 +469,7 @@ impl Choices {
|
||||
s.caps_lock = self.caps_lock;
|
||||
s.eyedropper_sample = self.eyedropper_sample;
|
||||
s.global_edit_options = self.global_edit_options;
|
||||
s.select_similar = self.select_similar;
|
||||
s.recent_urls = self.recent_urls;
|
||||
s.gradient_stop = self.gradient_stop;
|
||||
s.freeform_point = self.freeform_point;
|
||||
@@ -480,6 +483,7 @@ impl Choices {
|
||||
swap(&mut self.caps_lock, &mut s.caps_lock);
|
||||
swap(&mut self.eyedropper_sample, &mut s.eyedropper_sample);
|
||||
swap(&mut self.global_edit_options, &mut s.global_edit_options);
|
||||
swap(&mut self.select_similar, &mut s.select_similar);
|
||||
swap(&mut self.recent_urls, &mut s.recent_urls);
|
||||
swap(&mut self.gradient_stop, &mut s.gradient_stop);
|
||||
swap(&mut self.freeform_point, &mut s.freeform_point);
|
||||
|
||||
@@ -141,6 +141,7 @@ impl Session {
|
||||
link_stamps: Default::default(),
|
||||
link_scan: None,
|
||||
global_edit_options: Default::default(),
|
||||
select_similar: crate::cmd::SIMILAR[0].0,
|
||||
keyboard_text: false,
|
||||
script_key: None,
|
||||
script_engines: crate::script::EnginesKey::new(),
|
||||
|
||||
@@ -84,6 +84,63 @@ fn release_and_ungroup_keep_the_clipping_paths_paint() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels of `doc` rendered on white that are mostly blue and mostly green.
|
||||
fn blue_and_green(doc: &Document) -> (usize, usize) {
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let r = Renderer::new().render(doc, 200, 200, Affine::IDENTITY, &opts);
|
||||
let (mut blue, mut green) = (0, 0);
|
||||
for y in 0..200 {
|
||||
for x in 0..200 {
|
||||
let p = r.pixel(x, y);
|
||||
blue += usize::from(p[2] > 200 && p[0] < 60 && p[1] < 60);
|
||||
green += usize::from(p[1] > 200 && p[0] < 60 && p[2] < 60);
|
||||
}
|
||||
}
|
||||
(blue, green)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn painted_type_clipping_inside_a_group_with_geometry_effects_keeps_its_paint() {
|
||||
// The canvas and the SVG and PDF output paint the type's fill and stroke as they did before
|
||||
// the effect. Type that stays type beside the clip group takes the second Transform case
|
||||
// through baking that keeps type.
|
||||
let cases =
|
||||
[("distort.transform", json!({"moveH": 5}), false), ("distort.transform", json!({"moveH": 5}), true), ("distort.roughen", json!({}), false)];
|
||||
for (effect, params, other_type) in cases {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
rect(&mut s, 0.0, 30.0, "#ff0000");
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Clip", "size": 72})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let clip = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [t.0]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#00ff00", "ids": [t.0]})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 2, "ids": [t.0]})).unwrap();
|
||||
let before = blue_and_green(&s.doc().unwrap().doc);
|
||||
let mut ids = vec![clip.0];
|
||||
if other_type {
|
||||
ids.push(s.execute("text.create", &json!({"x": 10, "y": 190, "text": "x", "size": 10})).unwrap()["id"].as_u64().unwrap());
|
||||
}
|
||||
let group = NodeId(s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"effect": effect, "params": params, "ids": [group.0], "item": null})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let (blue, green) = blue_and_green(&doc);
|
||||
assert!(
|
||||
blue * 2 > before.0 && green * 2 > before.1,
|
||||
"{effect} {other_type}: the canvas paints the type's fill and stroke: {blue} {green}, {before:?} before"
|
||||
);
|
||||
let svg = vectorcraft_svg::export(&doc, &Default::default());
|
||||
let pdf = vectorcraft_pdf::export(&doc, &Default::default()).unwrap();
|
||||
for (what, out) in [("SVG", vectorcraft_svg::import(&svg).unwrap()), ("PDF", vectorcraft_pdf::import(&pdf).unwrap())] {
|
||||
let (b, g) = blue_and_green(&out);
|
||||
assert!(
|
||||
b.abs_diff(blue) * 10 < blue && g.abs_diff(green) * 5 < green,
|
||||
"{effect} {other_type}, {what}: {b} {g} against the canvas's {blue} {green}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_as_a_clipping_path_paints_its_characters() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1791,3 +1791,350 @@ fn a_recovered_copy_of_an_imported_file_keeps_its_links_folder() {
|
||||
let mut saved: Value = serde_json::from_slice(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("src/photo.png"));
|
||||
}
|
||||
|
||||
/// Image `id` of the active document without pixels in the document, as a linked file that could
|
||||
/// not be read leaves it.
|
||||
fn without_pixels(s: &mut Session, id: NodeId) {
|
||||
let key = image(s, id).key;
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).images.remove(&key);
|
||||
}
|
||||
|
||||
/// The text of `document.serialize`'s reply `r` (an SVG, or the SVGZ it holds).
|
||||
fn svg_text(r: &Value) -> String {
|
||||
match r["text"].as_str() {
|
||||
Some(text) => text.to_string(),
|
||||
None => {
|
||||
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
vectorcraft_svg::text_of(&bytes).unwrap().into_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A caller confined to read roots: every SVG and SVGZ output that would name a linked image's file
|
||||
/// outside them (Images: Link, Preserve Editing Capabilities, or Embed for an image without pixels
|
||||
/// in the document) is refused before a file or folder is written, with an error that names no
|
||||
/// file or folder, and the document keeps the link. Embed with the pixels names no file.
|
||||
#[test]
|
||||
fn confined_svg_output_refuses_a_linked_image_file_outside_the_read_roots() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("svg-image-away");
|
||||
let home = Folder::new("svg-image-home");
|
||||
let photo = away.file("photo.png");
|
||||
write(&photo, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let a = place(&mut s, &photo);
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("assets.add", &json!({"ids": [a.0]})).unwrap();
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let hidden = [away.0.to_string_lossy().into_owned(), home.0.to_string_lossy().into_owned(), "photo.png".to_string()];
|
||||
let refused = |s: &mut Session, cmd: &str, p: Value| {
|
||||
let e = confine(roots.as_ref(), || s.execute(cmd, &p)).expect_err(cmd).to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{cmd} {p}: {e}");
|
||||
for h in &hidden {
|
||||
assert!(!e.contains(h.as_str()), "{cmd} {p} names {h}: {e}");
|
||||
}
|
||||
};
|
||||
let out = home.file("out");
|
||||
// Every SVG output, with the options `o` given flat.
|
||||
let every = |s: &mut Session, o: &Value| {
|
||||
for format in ["svg", "svgz"] {
|
||||
let file = |name: &str| home.file(&format!("{name}.{format}"));
|
||||
let with = |p: Value| {
|
||||
let mut p = p;
|
||||
if let (Some(m), Some(o)) = (p.as_object_mut(), o.as_object()) {
|
||||
m.extend(o.clone());
|
||||
}
|
||||
p
|
||||
};
|
||||
refused(s, "document.serialize", with(json!({"format": format})));
|
||||
refused(s, "document.export", with(json!({"format": format})));
|
||||
refused(s, "document.export", with(json!({"format": format, "path": file("export")})));
|
||||
refused(s, "document.export", with(json!({"format": format, "selectedOnly": true})));
|
||||
refused(s, "document.exportSelection", with(json!({"format": format, "path": file("selection")})));
|
||||
refused(s, "document.save", with(json!({"path": file("save")})));
|
||||
refused(s, "file.saveAs", with(json!({"format": format, "path": file("save-as")})));
|
||||
refused(s, "file.saveCopy", with(json!({"format": format, "path": file("copy")})));
|
||||
let row = with(json!({"format": format}));
|
||||
refused(s, "document.exportForScreens", json!({"folder": out, "formats": [{"format": "png"}, row]}));
|
||||
refused(s, "document.exportForScreens", json!({"zip": true, "formats": [row]}));
|
||||
refused(s, "assets.export", json!({"folder": out, "formats": [row]}));
|
||||
}
|
||||
};
|
||||
every(&mut s, &json!({"images": "link"}));
|
||||
every(&mut s, &json!({"preserveEditing": true}));
|
||||
refused(&mut s, "document.serialize", json!({"format": "svg", "svg": {"images": "link"}}));
|
||||
// Embedded pixels name no file.
|
||||
for format in ["svg", "svgz"] {
|
||||
let r = confine(roots.as_ref(), || s.execute("document.serialize", &json!({"format": format}))).unwrap();
|
||||
let text = svg_text(&r);
|
||||
assert!(text.contains("data:image/png;base64,") && !text.contains(&hidden[0]), "{format}: {text}");
|
||||
}
|
||||
// Without its pixels, an embedded image names its file.
|
||||
without_pixels(&mut s, a);
|
||||
let r = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
assert!(svg_text(&r).contains(&format!("xlink:href=\"{photo}\"")), "{r}");
|
||||
every(&mut s, &json!({}));
|
||||
let written: Vec<_> = std::fs::read_dir(&home.0).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert!(written.is_empty(), "{written:?}");
|
||||
assert_eq!(image(&s, a).link.map(|l| l.path), Some(photo));
|
||||
}
|
||||
|
||||
/// A confined caller's SVG leaves out hidden layers and objects, unless hidden layers are kept (as
|
||||
/// a save keeps them by default), and template layers always: a linked image file outside the read
|
||||
/// roots in art the file leaves out does not refuse it, and in art the file writes does.
|
||||
#[test]
|
||||
fn confined_svg_output_checks_only_the_linked_images_it_writes() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("svg-image-unwritten-away");
|
||||
let home = Folder::new("svg-image-unwritten-home");
|
||||
let photo = away.file("photo.png");
|
||||
write(&photo, &png(60, 30, RED));
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
// A visible rectangle, and the linked image in a layer of its own.
|
||||
let setup = || {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
let layer = NodeId(s.execute("layer.new", &json!({"top": true})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("layer.setCurrent", &json!({"id": layer.0})).unwrap();
|
||||
let a = place(&mut s, &photo);
|
||||
assert!(s.doc().unwrap().doc.node(layer).unwrap().children().unwrap().iter().any(|c| c.id == a), "the image is in the new layer");
|
||||
(s, layer, a)
|
||||
};
|
||||
let written = |s: &mut Session, cmd: &str, p: Value| {
|
||||
let r = confine(roots.as_ref(), || s.execute(cmd, &p)).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
if cmd == "document.serialize" {
|
||||
let text = svg_text(&r);
|
||||
assert!(!text.contains(&photo) && !text.contains("photo.png"), "{cmd} {p}: {text}");
|
||||
}
|
||||
};
|
||||
let refused = |s: &mut Session, cmd: &str, p: Value| {
|
||||
let e = confine(roots.as_ref(), || s.execute(cmd, &p)).expect_err(cmd).to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{cmd} {p}: {e}");
|
||||
};
|
||||
// Exports leave hidden art out; hidden layers kept, and a save, write it.
|
||||
let hidden_art = |s: &mut Session, name: &str| {
|
||||
for format in ["svg", "svgz"] {
|
||||
written(s, "document.serialize", json!({"format": format, "images": "link"}));
|
||||
written(s, "document.export", json!({"format": format, "images": "link", "path": home.file(&format!("{name}.{format}"))}));
|
||||
written(s, "document.export", json!({"format": format, "images": "link", "svg": {"hiddenLayers": false}}));
|
||||
refused(s, "document.serialize", json!({"format": format, "images": "link", "hiddenLayers": true}));
|
||||
refused(s, "document.export", json!({"format": format, "svg": {"images": "link", "hiddenLayers": true}}));
|
||||
refused(s, "document.save", json!({"path": home.file(&format!("{name}-save.{format}")), "images": "link"}));
|
||||
refused(s, "file.saveCopy", json!({"format": format, "path": home.file(&format!("{name}-copy.{format}")), "images": "link"}));
|
||||
}
|
||||
// A save told to leave hidden layers out leaves the image out too.
|
||||
let copy = home.file(&format!("{name}-copy.svg"));
|
||||
written(s, "file.saveCopy", json!({"format": "svg", "path": copy, "images": "link", "hiddenLayers": false}));
|
||||
};
|
||||
let (mut s, layer, _) = setup();
|
||||
s.execute("layer.setProps", &json!({"ids": [layer.0], "visible": false})).unwrap();
|
||||
hidden_art(&mut s, "hidden-layer");
|
||||
let (mut s, _, a) = setup();
|
||||
s.execute("object.hide", &json!({"ids": [a.0]})).unwrap();
|
||||
hidden_art(&mut s, "hidden-object");
|
||||
// A template layer is never written, not even with hidden layers kept.
|
||||
let (mut s, layer, _) = setup();
|
||||
s.execute("layer.setProps", &json!({"ids": [layer.0], "template": true})).unwrap();
|
||||
for format in ["svg", "svgz"] {
|
||||
written(&mut s, "document.serialize", json!({"format": format, "images": "link"}));
|
||||
written(&mut s, "document.serialize", json!({"format": format, "images": "link", "hiddenLayers": true}));
|
||||
written(&mut s, "file.saveCopy", json!({"format": format, "path": home.file(&format!("template-copy.{format}")), "images": "link"}));
|
||||
}
|
||||
// Back to an ordinary layer, the image is written and refuses the file.
|
||||
s.execute("layer.setProps", &json!({"ids": [layer.0], "template": false})).unwrap();
|
||||
refused(&mut s, "document.serialize", json!({"format": "svg", "images": "link"}));
|
||||
// The layer of symbol editing mode is never written: editing a symbol no art uses leaves its
|
||||
// image out of the file.
|
||||
let (mut s, _, a) = setup();
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let instance = s.execute("symbol.new", &json!({"name": "Away"})).unwrap()["id"].clone();
|
||||
s.execute("edit.clear", &json!({"ids": [instance]})).unwrap();
|
||||
s.execute("symbol.edit", &json!({"name": "Away"})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.symbol_edit.is_some(), "in symbol editing mode");
|
||||
for format in ["svg", "svgz"] {
|
||||
written(&mut s, "document.serialize", json!({"format": format, "images": "link", "hiddenLayers": true}));
|
||||
written(&mut s, "file.saveCopy", json!({"format": format, "path": home.file(&format!("symbol-edit-copy.{format}")), "images": "link"}));
|
||||
}
|
||||
s.execute("symbol.exitEditing", &json!({})).unwrap();
|
||||
s.execute("symbol.place", &json!({"name": "Away"})).unwrap();
|
||||
refused(&mut s, "document.serialize", json!({"format": "svg", "images": "link"}));
|
||||
}
|
||||
|
||||
/// A confined caller's SVG of a grayscale linked image outside the read roots that a solid fill
|
||||
/// colorizes: the file gets the colorized pixels, or leaves the image out without pixels, and names
|
||||
/// no file with Link or Embed. With Preserve Editing Capabilities the document the SVG carries
|
||||
/// keeps the link, and the SVG is refused.
|
||||
#[test]
|
||||
fn confined_svg_output_writes_a_colorized_gray_image_without_its_file() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("svg-image-colorized-away");
|
||||
let home = Folder::new("svg-image-colorized-home");
|
||||
let photo = away.file("gray.png");
|
||||
let mut bytes = vec![];
|
||||
image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap().write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
write(&photo, &bytes);
|
||||
let mut s = session();
|
||||
let a = place(&mut s, &photo);
|
||||
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap();
|
||||
let colorized = s.doc().unwrap().doc.node(a).unwrap().extras().colorize.clone();
|
||||
assert!(matches!(colorized.as_deref(), Some(vectorcraft_color::Paint::Solid { .. })), "{colorized:?}");
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let written = |s: &mut Session, cmd: &str, p: Value| {
|
||||
let r = confine(roots.as_ref(), || s.execute(cmd, &p)).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
if cmd == "document.serialize" {
|
||||
let text = svg_text(&r);
|
||||
assert!(!text.contains(&away.0.to_string_lossy().into_owned()) && !text.contains("href=\"gray.png"), "{cmd} {p}: {text}");
|
||||
}
|
||||
};
|
||||
let refused = |s: &mut Session, p: Value| {
|
||||
let e = confine(roots.as_ref(), || s.execute("document.serialize", &p)).expect_err("preserveEditing").to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{p}: {e}");
|
||||
};
|
||||
let every = |s: &mut Session, name: &str| {
|
||||
for format in ["svg", "svgz"] {
|
||||
for images in ["link", "embed"] {
|
||||
written(s, "document.serialize", json!({"format": format, "images": images}));
|
||||
written(s, "document.export", json!({"format": format, "images": images, "path": home.file(&format!("{name}-{images}.{format}"))}));
|
||||
let copy = home.file(&format!("{name}-{images}-copy.{format}"));
|
||||
written(s, "file.saveCopy", json!({"format": format, "images": images, "path": copy}));
|
||||
}
|
||||
refused(s, json!({"format": format, "preserveEditing": true}));
|
||||
}
|
||||
};
|
||||
let text = svg_text(&confine(roots.as_ref(), || s.execute("document.serialize", &json!({"format": "svg"}))).unwrap());
|
||||
assert!(text.contains("data:image/png;base64,"), "{text}");
|
||||
every(&mut s, "pixels");
|
||||
without_pixels(&mut s, a);
|
||||
every(&mut s, "missing");
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let svg = confine(roots.as_ref(), || s.execute("clipboard.exportSvg", &json!({}))).unwrap()["svg"].as_str().unwrap().to_string();
|
||||
assert!(!svg.contains(&photo), "{svg}");
|
||||
assert_eq!(image(&s, a).link.map(|l| l.path), Some(photo));
|
||||
}
|
||||
|
||||
/// A confined caller's SVG names each linked image file it may read, as stored: a file inside the
|
||||
/// read roots, a path relative to the document's folder that leads inside them, and a URL. The
|
||||
/// selected objects alone are exported when the image outside them is not selected, and a caller
|
||||
/// without roots gets a file outside them named too.
|
||||
#[test]
|
||||
fn svg_output_names_linked_image_files_a_caller_may_read() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("svg-image-kept-away");
|
||||
let home = Folder::new("svg-image-kept-home");
|
||||
let inside = home.file("photo.png");
|
||||
let (nested, web) = (home.file("lib/photo.png"), home.file("web.png"));
|
||||
for f in [&inside, &nested, &web] {
|
||||
write(f, &png(60, 30, RED));
|
||||
}
|
||||
let url = "https://example.com/images/photo.png";
|
||||
let mut s = session();
|
||||
place(&mut s, &inside);
|
||||
place(&mut s, &nested);
|
||||
place(&mut s, &web);
|
||||
save(&mut s, &home.file("doc.vectorcraft"));
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
doc.update_links_where(|l| l.path == nested, |l| l.path = "lib/photo.png".into());
|
||||
doc.update_links_where(|l| l.path == web, |l| l.path = url.into());
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let link = json!({"format": "svg", "images": "link"});
|
||||
for format in ["svg", "svgz"] {
|
||||
let p = json!({"format": format, "images": "link"});
|
||||
let text = svg_text(&confine(roots.as_ref(), || s.execute("document.serialize", &p)).unwrap());
|
||||
for file in [inside.as_str(), "lib/photo.png", url] {
|
||||
assert!(text.contains(&format!("xlink:href=\"{file}\"")), "{format} {file}: {text}");
|
||||
}
|
||||
}
|
||||
// An image outside them in a symbol no art uses is not written.
|
||||
let unused = away.file("unused.png");
|
||||
write(&unused, &png(60, 30, BLUE));
|
||||
let u = place(&mut s, &unused);
|
||||
s.execute("select.set", &json!({"ids": [u.0]})).unwrap();
|
||||
let instance = s.execute("symbol.new", &json!({"name": "Away"})).unwrap()["id"].clone();
|
||||
s.execute("edit.clear", &json!({"ids": [instance]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.symbols.len(), 1);
|
||||
for format in ["svg", "svgz"] {
|
||||
let p = json!({"format": format, "images": "link"});
|
||||
let text = svg_text(&confine(roots.as_ref(), || s.execute("document.serialize", &p)).unwrap());
|
||||
assert!(!text.contains(&unused), "{format}: {text}");
|
||||
}
|
||||
let outside = away.file("photo.png");
|
||||
write(&outside, &png(60, 30, BLUE));
|
||||
place(&mut s, &outside);
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r["id"].clone()]})).unwrap();
|
||||
let p = json!({"format": "svg", "images": "link"});
|
||||
let r = confine(roots.as_ref(), || s.execute("document.exportSelection", &p)).unwrap();
|
||||
assert!(!r.to_string().contains(&outside), "{r}");
|
||||
let p = json!({"format": "svg", "images": "link", "selectedOnly": true});
|
||||
for cmd in ["document.serialize", "document.export"] {
|
||||
let r = confine(roots.as_ref(), || s.execute(cmd, &p)).unwrap();
|
||||
assert!(!r.to_string().contains(&outside), "{cmd}: {r}");
|
||||
}
|
||||
// The file of an artboard the image is not over.
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap();
|
||||
let p = json!({"format": "svg", "images": "link", "range": "2"});
|
||||
let r = confine(roots.as_ref(), || s.execute("document.serialize", &p)).unwrap();
|
||||
assert!(!r.to_string().contains(&outside), "{r}");
|
||||
let p = json!({"folder": home.file("screens"), "formats": [{"format": "svg", "images": "link"}], "range": "2"});
|
||||
confine(roots.as_ref(), || s.execute("document.exportForScreens", &p)).unwrap();
|
||||
// Without roots, a file outside them is named as stored.
|
||||
let text = svg_text(&s.execute("document.serialize", &link).unwrap());
|
||||
assert!(text.contains(&format!("xlink:href=\"{outside}\"")), "{text}");
|
||||
}
|
||||
|
||||
/// The clipboard's SVG for a confined caller: embedded pixels name no file; an image whose pixels
|
||||
/// the clipboard doesn't hold makes `clipboard.exportSvg` refuse with an error that names no file
|
||||
/// or folder, and the system clipboard gets no SVG. A caller without roots gets the file named.
|
||||
#[test]
|
||||
fn confined_clipboard_svg_refuses_a_linked_image_file_outside_the_read_roots() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("clip-image-away");
|
||||
let home = Folder::new("clip-image-home");
|
||||
let photo = away.file("photo.png");
|
||||
write(&photo, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let a = place(&mut s, &photo);
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let r = confine(roots.as_ref(), || s.execute("clipboard.exportSvg", &json!({}))).unwrap();
|
||||
let svg = r["svg"].as_str().unwrap();
|
||||
assert!(svg.contains("data:image/png;base64,") && !svg.contains(&photo), "{svg}");
|
||||
|
||||
without_pixels(&mut s, a);
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let e = confine(roots.as_ref(), || s.execute("clipboard.exportSvg", &json!({}))).unwrap_err().to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{e}");
|
||||
assert!(!e.contains(&away.0.to_string_lossy().into_owned()) && !e.contains("photo.png"), "{e}");
|
||||
assert!(confine(roots.as_ref(), || s.clipboard_svg()).is_none());
|
||||
let svg = s.execute("clipboard.exportSvg", &json!({})).unwrap()["svg"].as_str().unwrap().to_string();
|
||||
assert!(svg.contains(&format!("xlink:href=\"{photo}\"")), "{svg}");
|
||||
}
|
||||
|
||||
/// A confined caller's SVG with Preserve Editing Capabilities is refused while the stored brush
|
||||
/// library holds an entry that does not read as a brush, or is not a list: the native document
|
||||
/// carries it as it is, and its links cannot be checked. Without Preserve Editing the SVG is written.
|
||||
#[test]
|
||||
fn confined_preserve_editing_svg_refuses_a_brush_library_entry_it_cannot_read() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let home = Folder::new("svg-brush-entry-home");
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let preserve = json!({"format": "svg", "preserveEditing": true});
|
||||
confine(roots.as_ref(), || s.execute("document.serialize", &preserve)).unwrap();
|
||||
for stored in [json!(["not a brush"]), json!({"link": "/away/photo.png"})] {
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
doc.unknown.insert(vectorcraft_brush::DOC_KEY.into(), stored.clone());
|
||||
let e = confine(roots.as_ref(), || s.execute("document.serialize", &preserve)).unwrap_err().to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{stored}: {e}");
|
||||
confine(roots.as_ref(), || s.execute("document.serialize", &json!({"format": "svg"}))).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2789,6 +2789,73 @@ fn select_same_fill_keeps_live_paint_faces_to_their_group() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Select Similar Objects (`select.similar`) runs the Select > Same command of the attribute it is
|
||||
/// given and keeps that attribute for its next run without one; `all`, the attribute until one is
|
||||
/// given, matches the fill and stroke colours, the stroke weight, the opacity and the blending mode
|
||||
/// together. An unknown attribute is an error that keeps the selection and the kept attribute.
|
||||
#[test]
|
||||
fn select_similar_selects_by_the_kept_attribute() {
|
||||
let mut s = session();
|
||||
let [a, b, faded, heavy, multiplied, blue] = [0.0, 20.0, 40.0, 60.0, 80.0, 100.0].map(|x| rect(&mut s, x, 0.0, 10.0, 10.0));
|
||||
for id in [a, b, faded, heavy, multiplied] {
|
||||
fill(&mut s, id, "#ff0000");
|
||||
}
|
||||
fill(&mut s, blue, "#0000ff");
|
||||
s.execute("transparency.set", &json!({"ids": [faded.0], "opacity": 50, "item": null})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [multiplied.0], "blend": "multiply", "item": null})).unwrap();
|
||||
sel(&mut s, &[heavy]);
|
||||
s.execute("stroke.set", &json!({"weight": 3})).unwrap();
|
||||
let similar = |s: &mut Session, p: Value| {
|
||||
sel(s, &[a]);
|
||||
let r = s.execute("select.similar", &p).unwrap();
|
||||
let mut v = selected(s);
|
||||
v.sort();
|
||||
assert_eq!(r["count"], v.len(), "{p}");
|
||||
(r["by"].as_str().unwrap().to_string(), v)
|
||||
};
|
||||
assert_eq!(s.select_similar, "all");
|
||||
assert_eq!(similar(&mut s, json!({})), ("all".into(), vec![a, b]));
|
||||
assert_eq!(similar(&mut s, json!({"by": "fillColor"})), ("fillColor".into(), vec![a, b, faded, heavy, multiplied]));
|
||||
assert_eq!(similar(&mut s, json!({})), ("fillColor".into(), vec![a, b, faded, heavy, multiplied]));
|
||||
// The journal, and an action recorded around it, keep the attribute used.
|
||||
let last = s.journal.last().unwrap();
|
||||
assert_eq!((last.0.as_str(), &last.1), ("select.similar", &json!({"by": "fillColor"})));
|
||||
assert_eq!(similar(&mut s, json!({"by": "opacity"})), ("opacity".into(), vec![a, b, heavy, multiplied, blue]));
|
||||
assert_eq!(similar(&mut s, json!({"by": "blendingMode"})), ("blendingMode".into(), vec![a, b, faded, heavy, blue]));
|
||||
assert_eq!(similar(&mut s, json!({"by": "strokeWeight"})), ("strokeWeight".into(), vec![a, b, faded, multiplied, blue]));
|
||||
assert_eq!(similar(&mut s, json!({"by": "all"})).1, vec![a, b]);
|
||||
// Select > Reselect repeats `all`, whichever attribute is kept by then.
|
||||
s.select_similar = "fillColor";
|
||||
s.execute("select.reselect", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a, b]);
|
||||
// Every attribute names a command that runs. A path with no graphic style has none to match,
|
||||
// which is an error that keeps the attribute as it was.
|
||||
for (by, _) in crate::cmd::SIMILAR {
|
||||
sel(&mut s, &[a]);
|
||||
let was = s.select_similar;
|
||||
let r = s.execute("select.similar", &json!({ "by": by }));
|
||||
if by == "graphicStyle" {
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
assert_eq!(s.select_similar, was);
|
||||
} else {
|
||||
r.unwrap_or_else(|e| panic!("{by}: {e:?}"));
|
||||
assert_eq!(s.select_similar, by);
|
||||
}
|
||||
}
|
||||
// An unknown or malformed attribute changes nothing.
|
||||
s.select_similar = "opacity";
|
||||
for bad in [json!("color"), json!(""), json!("FillColor"), json!(3), json!(true), json!(["all"])] {
|
||||
sel(&mut s, &[a]);
|
||||
let r = s.execute("select.similar", &json!({ "by": bad }));
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}");
|
||||
assert_eq!((selected(&s), s.select_similar), (vec![a], "opacity"), "{bad}");
|
||||
}
|
||||
// With nothing selected it does not run, and keeps the attribute.
|
||||
sel(&mut s, &[]);
|
||||
assert!(s.execute("select.similar", &json!({"by": "fillColor"})).is_err());
|
||||
assert_eq!(s.select_similar, "opacity");
|
||||
}
|
||||
|
||||
/// Select by Color needs no object to start from: it matches fills and strokes as
|
||||
/// `document.inspect` reports them, `#rrggbb`.
|
||||
#[test]
|
||||
|
||||
@@ -52,6 +52,39 @@ fn invert_and_complement_recolour_the_active_item() {
|
||||
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#ff0000".into(), "#0000ff".into()));
|
||||
}
|
||||
|
||||
/// With a mixed selection, Invert on the Appearance panel's active row remembers the color of the row it recolors in the
|
||||
/// first object, the row equal to the active one, which need not have the active row's index.
|
||||
#[test]
|
||||
fn invert_of_the_active_row_remembers_the_equal_row_it_recolors() {
|
||||
let mut s = session();
|
||||
let rect = |s: &mut Session, x: f64| {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
};
|
||||
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 100.0));
|
||||
// `a` paints first: [red Fill, Stroke, blue Fill]. `b`, selected first: [green Fill, red Fill, Stroke].
|
||||
let look = Appearance::basic(Paint::solid(Color::from_hex("#ff0000").unwrap()), Paint::solid(Color::BLACK), 1.0);
|
||||
let (mut a_look, mut b_look) = (look.clone(), look.clone());
|
||||
let (mut green, mut blue) = (look.items[0].clone(), look.items[0].clone());
|
||||
*green.paint_mut() = Paint::solid(Color::from_hex("#00ff00").unwrap());
|
||||
*blue.paint_mut() = Paint::solid(Color::from_hex("#0000ff").unwrap());
|
||||
b_look.items.insert(0, green);
|
||||
a_look.items.push(blue);
|
||||
s.edit("Looks", |d, _| {
|
||||
d.node_mut(a).unwrap().appearance = vectorcraft_doc::Shared::new(a_look.clone());
|
||||
d.node_mut(b).unwrap().appearance = vectorcraft_doc::Shared::new(b_look.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": [b.0, a.0]})).unwrap();
|
||||
s.execute("appearance.setActiveItem", &json!({"index": 1})).unwrap();
|
||||
s.last_solid = Color::from_hex("#808080").unwrap();
|
||||
s.execute("paint.invert", &json!({})).unwrap();
|
||||
let hexes = (item_hex(&s, a, 0), item_hex(&s, a, 2), item_hex(&s, b, 0), item_hex(&s, b, 1));
|
||||
assert_eq!(hexes, ("#00ffff".into(), "#0000ff".into(), "#00ff00".into(), "#00ffff".into()));
|
||||
assert_eq!(s.last_solid.to_hex(), "#00ffff", "the last color is the inverted red, not row 1 of the first object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mixed_proxy_compares_the_active_item() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -290,6 +290,11 @@ fn a_failed_run_puts_back_the_choices_its_commands_made() {
|
||||
run(&mut s, source).unwrap();
|
||||
assert_eq!(s.global_edit_options, before, "the failed command's choice went back");
|
||||
|
||||
let source = "vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10}); \
|
||||
if (vc.run('select.similar', {by: 'opacity'}).by !== 'opacity') throw new Error('not run'); throw new Error('x');";
|
||||
assert!(matches!(run(&mut s, source), Err(e) if !format!("{e:?}").contains("not run")));
|
||||
assert_eq!(s.select_similar, "all", "the Select Similar Objects attribute went back");
|
||||
|
||||
assert!(!s.caps_lock);
|
||||
assert!(run(&mut s, "vc.run('view.capsLock', {on: true}); throw new Error('x');").is_err());
|
||||
assert!(!s.caps_lock, "the Caps Lock state went back");
|
||||
|
||||
@@ -45,6 +45,7 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects()
|
||||
assert!(libs.iter().any(|l| l["id"] == "game-hud" && l["name"] == "Game HUD"), "{libs:?}");
|
||||
assert!(libs.iter().any(|l| l["id"] == "status-effects" && l["name"] == "Status Effects"), "{libs:?}");
|
||||
assert!(libs.iter().any(|l| l["id"] == "textures" && l["name"] == "Textures" && l["count"] == 8), "{libs:?}");
|
||||
assert!(libs.iter().any(|l| l["id"] == "neon-glow" && l["name"] == "Neon & Glow" && l["count"] == 6), "{libs:?}");
|
||||
for l in libs {
|
||||
let id = l["id"].as_str().unwrap();
|
||||
let g = run(&mut s, "graphicStyle.library", json!({"library": id}));
|
||||
@@ -69,6 +70,9 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects()
|
||||
let texture_names: Vec<&str> = textures["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
|
||||
let expected = ["Fine Grain", "Canvas Weave", "Burlap Sack", "Cracked Glaze", "Mosaic Floor", "Quilted Squares", "Leaded Glass", "Damp Paper"];
|
||||
assert_eq!(texture_names, expected);
|
||||
let neon = run(&mut s, "graphicStyle.library", json!({"library": "neon-glow"}));
|
||||
let neon_names: Vec<&str> = neon["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(neon_names, ["Cyan Neon", "Magenta Laser", "Amber Tube", "Electric Lime", "Pulse Violet", "Cobalt Phosphor"]);
|
||||
let id = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
|
||||
let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "game-hud", "name": "Mana Glow", "apply": true, "ids": [id.0]}));
|
||||
assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec!["Mana Glow"], Some("Mana Glow")));
|
||||
@@ -419,3 +423,53 @@ fn every_builtin_style_paints_visibly_on_a_rectangle() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neon_and_glow_library_adds_and_applies_multi_stroke_styles() {
|
||||
let mut s = session();
|
||||
let list = run(&mut s, "graphicStyle.libraries", json!({}));
|
||||
let libs = list["libraries"].as_array().unwrap();
|
||||
let at = libs.iter().position(|l| l["id"] == "neon-glow").expect("neon-glow listed");
|
||||
assert_eq!(libs[at]["name"], "Neon & Glow");
|
||||
assert_eq!(libs[at]["count"], 6);
|
||||
|
||||
let (_, lib) = cmd::stylelib::library(&s, "neon-glow").unwrap();
|
||||
assert_eq!(lib.len(), 6);
|
||||
|
||||
let want = [
|
||||
("Cyan Neon", 2, "stylize.dropShadow"),
|
||||
("Magenta Laser", 2, "stylize.outerGlow"),
|
||||
("Amber Tube", 2, "stylize.outerGlow"),
|
||||
("Electric Lime", 2, "stylize.outerGlow"),
|
||||
("Pulse Violet", 2, "stylize.innerGlow"),
|
||||
("Cobalt Phosphor", 2, "blur.gaussian"),
|
||||
];
|
||||
|
||||
for (i, (name, stroke_count, first_effect)) in want.iter().enumerate() {
|
||||
let id = rect(&mut s, 10.0 + 30.0 * i as f64, 15.0, 25.0, 25.0);
|
||||
let before_undo = undo_len(&s);
|
||||
let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "neon-glow", "name": name, "apply": true, "ids": [id.0]}));
|
||||
assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec![*name], Some(*name)));
|
||||
assert_eq!(undo_len(&s), before_undo + 1, "{name}");
|
||||
|
||||
let node = doc(&s).node(id).unwrap();
|
||||
let strokes = node.appearance.items.iter().filter(|it| matches!(it, vectorcraft_doc::AppearanceItem::Stroke(_))).count();
|
||||
assert_eq!(strokes, *stroke_count, "{name}");
|
||||
assert_eq!(node.appearance.effects[0].id, *first_effect, "{name}");
|
||||
}
|
||||
|
||||
// Adding existing style dedupes and creates no new undo step.
|
||||
let before = (undo_len(&s), doc(&s).graphic_styles.len());
|
||||
let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "neon-glow", "name": "Cyan Neon"}));
|
||||
assert_eq!((names(&r["added"]), names(&r["existing"])), (vec![], vec!["Cyan Neon"]));
|
||||
assert_eq!((undo_len(&s), doc(&s).graphic_styles.len()), before);
|
||||
|
||||
// Round-trip through serialize / open
|
||||
let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
|
||||
run(&mut s, "document.open", json!({"name": "neon.vectorcraft", "dataBase64": b64}));
|
||||
for (name, _, _) in want {
|
||||
let d = doc(&s);
|
||||
let st = d.graphic_style(name).unwrap();
|
||||
assert!(st.same_look(lib.style(name).unwrap()), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1103,6 +1103,12 @@ impl Session {
|
||||
self.pointer_held
|
||||
}
|
||||
|
||||
/// What the Info panel shows for the active tool as it is now
|
||||
/// ([`vectorcraft_tools::Tool::readout`]), in the view of the last pointer event.
|
||||
pub fn tool_readout(&mut self) -> Option<vectorcraft_tools::Readout> {
|
||||
self.with_tool_cx(self.last_view, |t, cx| t.readout(cx))
|
||||
}
|
||||
|
||||
/// Is the active tool moving, scaling or rotating the selection with a drag?
|
||||
pub fn tool_transforming(&self) -> bool {
|
||||
self.tool.transforming()
|
||||
|
||||
@@ -4601,6 +4601,10 @@ proptest! {
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
|
||||
survive("mutated .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
survive("mutated .ai symbols, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?;
|
||||
// The same symbols as files of versions 10 to 13 place them, by a `TransformMatrix`.
|
||||
let older = mutate_text(&older_symbols_text(), cut, &edits);
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&older)].concat();
|
||||
survive("mutated older .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
// A symbol library: the same symbols and list, no layers.
|
||||
let library = mutate_text(&symbol_library_text(), cut, &edits);
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&library)].concat();
|
||||
@@ -4855,6 +4859,53 @@ fn the_symbols_fuzz_data_opens_live() {
|
||||
assert_eq!(instances, 3);
|
||||
}
|
||||
|
||||
/// [`symbols_text`] with its instances placed by a `TransformMatrix`, as files of versions 10 to
|
||||
/// 13 write them. Art space's origin is at canvas (8091, 8242), from the template box: on the page
|
||||
/// the matrix takes the symbol's art to the canvas (y down), and in S2's art to S2's space.
|
||||
fn older_symbols_text() -> String {
|
||||
let mut text = symbols_text();
|
||||
for (newer, older) in [
|
||||
("1 0 0 -1 -8081 8237 /RTransformMatrix", "1 0 0 1 10 5 /TransformMatrix"),
|
||||
("1 0 0 -1 20 50 /RTransformMatrix", "1 0 0 1 8111 8192 /TransformMatrix"),
|
||||
("2 0 0 -2 100 50 /RTransformMatrix", "2 0 0 2 8191 8192 /TransformMatrix"),
|
||||
("1 0 0 -1 20 20 /RTransformMatrix", "1 0 0 1 8111 8222 /TransformMatrix"),
|
||||
] {
|
||||
assert!(text.contains(newer), "{newer}");
|
||||
text = text.replace(newer, older);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// The older data [`ai_symbols_never_panic`] damages, as it is, opens with its instances live and
|
||||
/// placed as [`symbols_text`] places them, on the page and in S2's art.
|
||||
#[test]
|
||||
fn the_older_symbols_fuzz_data_opens_live() {
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
fn xfs(n: &Node, out: &mut Vec<[f64; 6]>) {
|
||||
match &n.kind {
|
||||
NodeKind::SymbolInstance { xf, .. } => out.push(xf.as_coeffs()),
|
||||
_ => n.children().into_iter().flatten().for_each(|c| xfs(c, out)),
|
||||
}
|
||||
}
|
||||
let open = |text: &str| {
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat();
|
||||
let (doc, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0);
|
||||
assert!(layered, "{warnings:?}");
|
||||
let (mut page, mut nested) = (vec![], vec![]);
|
||||
doc.layers.iter().for_each(|l| xfs(l, &mut page));
|
||||
let s2 = doc.symbols.iter().find(|s| s.name == "S2").expect("S2");
|
||||
xfs(&s2.art, &mut nested);
|
||||
(page, nested)
|
||||
};
|
||||
let text = older_symbols_text();
|
||||
assert!(!text.contains("/RTransformMatrix"));
|
||||
let (older, newer) = (open(&text), open(&symbols_text()));
|
||||
assert_eq!((older.0.len(), older.1.len()), (3, 1));
|
||||
let same = |a: &[[f64; 6]], b: &[[f64; 6]]| a.len() == b.len() && a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() < 1e-9);
|
||||
assert!(same(&older.0, &newer.0), "{older:?} {newer:?}");
|
||||
assert!(same(&older.1, &newer.1), "{older:?} {newer:?}");
|
||||
}
|
||||
|
||||
/// Symbols the reader must stop at: sections that don't end, references and matrices it can't
|
||||
/// use, definitions nested deeper than it reads, and symbols that hold each other.
|
||||
#[test]
|
||||
|
||||
@@ -10457,7 +10457,7 @@ fn an_envelope_is_written_in_the_order_the_reader_reads_it() {
|
||||
/// Envelopes the reader would open in another shape, or not at all, are written as their distorted
|
||||
/// art with a warning, and open as that art: an envelope of type, one in a turned frame, a warp drawn
|
||||
/// without its style's net, a mesh the reader would lay out the other way round, a top object
|
||||
/// whose sides don't pair up, a Fidelity that isn't a whole number and bounds margins kept with
|
||||
/// with a side of more segments than the grid has columns, a Fidelity that isn't a whole number and bounds margins kept with
|
||||
/// Distort Appearance off. Other live objects keep their own warning.
|
||||
#[test]
|
||||
fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() {
|
||||
@@ -10494,15 +10494,10 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() {
|
||||
**kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: transpose(points, 3, 2), handles: vec![] };
|
||||
}
|
||||
});
|
||||
let mut uneven = kurbo::BezPath::new();
|
||||
uneven.move_to((30.0, 10.0));
|
||||
for p in [(100.0, 0.0), (170.0, 10.0), (170.0, 90.0), (30.0, 90.0)] {
|
||||
uneven.line_to(p);
|
||||
}
|
||||
uneven.close_path();
|
||||
let lopsided = change(&|k| {
|
||||
// A top side of 51 segments: more grid columns than the reader reads.
|
||||
let crowded = change(&|k| {
|
||||
if let NodeKind::Envelope { kind, .. } = k {
|
||||
**kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven), corners: None };
|
||||
**kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven_top_object(51)), corners: None };
|
||||
}
|
||||
});
|
||||
let fractional = change(&|k| {
|
||||
@@ -10521,7 +10516,7 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() {
|
||||
("turned", turned),
|
||||
("old warp", old_warp),
|
||||
("sideways mesh", sideways),
|
||||
("lopsided top object", lopsided),
|
||||
("top object past the grid's limit", crowded),
|
||||
("fractional fidelity", fractional),
|
||||
("margin kept", margin_kept),
|
||||
];
|
||||
@@ -10542,6 +10537,131 @@ fn transpose(points: Vec<kurbo::Point>, rows: usize, cols: usize) -> Vec<kurbo::
|
||||
(0..=cols).flat_map(|c| (0..=rows).map(move |r| (r, c))).map(|(r, c)| points[r * (cols + 1) + c]).collect()
|
||||
}
|
||||
|
||||
/// A top object over (30, 10)-(170, 90) whose top side is `top` straight segments, falling and
|
||||
/// rising by 10 pt between its anchors, and whose other sides are one straight segment each.
|
||||
fn uneven_top_object(top: usize) -> kurbo::BezPath {
|
||||
let mut path = kurbo::BezPath::new();
|
||||
path.move_to((30.0, 10.0));
|
||||
for k in 1..top {
|
||||
path.line_to((30.0 + 140.0 * k as f64 / top as f64, if k % 2 == 1 { 20.0 } else { 10.0 }));
|
||||
}
|
||||
for p in [(170.0, 10.0), (170.0, 90.0), (30.0, 90.0)] {
|
||||
path.line_to(p);
|
||||
}
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
|
||||
/// Do the patches of top objects `a` and `b` lie within 0.001 pt of each other at every place of a
|
||||
/// 32 × 32 grid?
|
||||
fn same_top_object_patch(a: &vectorcraft_doc::live::EnvelopeKind, b: &vectorcraft_doc::live::EnvelopeKind) -> bool {
|
||||
use vectorcraft_doc::live::{Coons, EnvelopeKind};
|
||||
let (EnvelopeKind::TopObject { path, corners }, EnvelopeKind::TopObject { path: p, corners: c }) = (a, b) else { return false };
|
||||
let (Some(a), Some(b)) = (Coons::from_top_object(path, *corners), Coons::from_top_object(p, *c)) else { return false };
|
||||
let at = |i: u32| f64::from(i) / 32.0;
|
||||
(0..=32).all(|j| (0..=32).all(|i| a.eval(at(i), at(j)).distance(b.eval(at(i), at(j))) < 1e-3))
|
||||
}
|
||||
|
||||
/// A top object whose top and bottom sides have different numbers of segments is written live, its
|
||||
/// side with fewer segments split into as many as the side across from it has, with Edit Contents off
|
||||
/// and on: two straight segments over one straight one, three over one, and two over one curve.
|
||||
/// VectorCraft's reader opens it as a top object with its corners on the same four points, the same
|
||||
/// patch, contents, Fidelity, Edit Contents and Envelope Options, and saved again it opens the same.
|
||||
#[test]
|
||||
fn a_top_object_with_uneven_sides_is_read_back_live() {
|
||||
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions};
|
||||
let mut curved = kurbo::BezPath::new();
|
||||
curved.move_to((30.0, 10.0));
|
||||
curved.line_to((100.0, 0.0));
|
||||
curved.line_to((170.0, 10.0));
|
||||
curved.line_to((170.0, 90.0));
|
||||
curved.curve_to((130.0, 105.0), (70.0, 105.0), (30.0, 90.0));
|
||||
curved.close_path();
|
||||
let shapes = [("two over one", uneven_top_object(2), 6), ("three over one", uneven_top_object(3), 8), ("two over a curve", curved, 6)];
|
||||
for (name, path, anchors) in shapes {
|
||||
let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None };
|
||||
for editing in [false, true] {
|
||||
let what = format!("{name}, editing {editing}");
|
||||
let doc = envelope_doc(kind.clone(), 50.0, editing, EnvelopeOptions::NEW);
|
||||
let text = program(&doc);
|
||||
assert!(text.contains("AIDeformOptionsKey") && text.contains("%_3 /Int (AIDeformStyle) ,\n"), "{what}: {text}");
|
||||
let (back, warnings, notes) = envelope_back(&doc);
|
||||
assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}: {warnings:?}");
|
||||
assert!(!notes.iter().any(|w| w == crate::import::ENVELOPE_DRAWN), "{what}: {notes:?}");
|
||||
let NodeKind::Envelope { content, kind: got, fidelity, editing: e, options, frame } = &back.kind else { panic!("{what}: {back:#?}") };
|
||||
assert_eq!((*fidelity, *e, *options, **frame), (50.0, editing, EnvelopeOptions::NEW, Affine::IDENTITY), "{what}");
|
||||
let EnvelopeKind::TopObject { path: outline, corners: Some(corners) } = &**got else { panic!("{what}: {got:?}") };
|
||||
let sp = &outline.subpaths[0];
|
||||
assert_eq!(sp.anchors.len(), anchors, "{what}: {outline:?}");
|
||||
let points = corners.map(|i| sp.anchors[i as usize].p);
|
||||
let want = [(30.0, 10.0), (170.0, 10.0), (170.0, 90.0), (30.0, 90.0)].map(kurbo::Point::from);
|
||||
assert!(points.iter().zip(want).all(|(a, b)| a.distance(b) < 1e-6), "{what}: {points:?}");
|
||||
assert!(same_top_object_patch(&kind, got), "{what}: {got:?}");
|
||||
let colors: Vec<Option<Color>> = content.iter().map(|c| c.appearance.fill_paint().color()).collect();
|
||||
assert_eq!(colors, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{what}");
|
||||
let mut again = Document::new(200.0, 100.0);
|
||||
let layer = again.layers[0].id;
|
||||
again.insert(Some(layer), usize::MAX, back.clone()).unwrap();
|
||||
let (twice, warnings, notes) = envelope_back(&again);
|
||||
assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}, saved again: {warnings:?}");
|
||||
let NodeKind::Envelope { kind: twice, .. } = &twice.kind else { panic!("{what}, saved again: {twice:#?} {notes:?}") };
|
||||
assert!(same_top_object_patch(&kind, twice), "{what}, saved again: {twice:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A top object with a top side of 50 segments over a bottom side of one is written live with a grid
|
||||
/// of 50 columns, the most the reader reads.
|
||||
#[test]
|
||||
fn a_top_object_split_to_the_grids_limit_is_read_back_live() {
|
||||
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions};
|
||||
let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven_top_object(50)), corners: None };
|
||||
let doc = envelope_doc(kind.clone(), 50.0, false, EnvelopeOptions::NEW);
|
||||
let text = program(&doc);
|
||||
assert!(text.contains("[50 1] /Size X#"), "{text}");
|
||||
let (back, warnings, notes) = envelope_back(&doc);
|
||||
assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}");
|
||||
let NodeKind::Envelope { kind: got, .. } = &back.kind else { panic!("{back:#?} {notes:?}") };
|
||||
assert!(same_top_object_patch(&kind, got), "{got:?}");
|
||||
}
|
||||
|
||||
/// A top object whose side with fewer segments would share out the content another way once split
|
||||
/// (two curves that aren't one curve, under three segments) is written as its art, with a warning.
|
||||
#[test]
|
||||
fn a_top_object_whose_split_side_would_map_another_way_is_written_as_its_art() {
|
||||
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions};
|
||||
let mut path = uneven_top_object(3);
|
||||
path.truncate(5);
|
||||
path.curve_to((150.0, 110.0), (120.0, 110.0), (100.0, 90.0));
|
||||
path.curve_to((80.0, 95.0), (40.0, 80.0), (30.0, 90.0));
|
||||
path.close_path();
|
||||
let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None };
|
||||
let doc = envelope_doc(kind, 50.0, false, EnvelopeOptions::NEW);
|
||||
assert!(!program(&doc).contains("AIDeformOptionsKey"));
|
||||
let (back, warnings, _) = envelope_back(&doc);
|
||||
assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}");
|
||||
assert!(matches!(back.kind, NodeKind::Group { .. }), "{back:#?}");
|
||||
}
|
||||
|
||||
/// A top object whose split side would map the content another way only between the places of an
|
||||
/// even grid of eighths is written as its art too: a bottom side of a long straight segment, which is
|
||||
/// split, and a short curve in its last eighth, which the reader then samples at fewer points.
|
||||
#[test]
|
||||
fn a_top_object_whose_split_side_differs_between_eighths_is_written_as_its_art() {
|
||||
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions};
|
||||
let mut path = uneven_top_object(3);
|
||||
path.truncate(5);
|
||||
path.curve_to((168.0, 85.0), (164.0, 85.0), (162.0, 90.0));
|
||||
path.line_to((30.0, 90.0));
|
||||
path.close_path();
|
||||
let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None };
|
||||
let doc = envelope_doc(kind, 50.0, false, EnvelopeOptions::NEW);
|
||||
assert!(!program(&doc).contains("AIDeformOptionsKey"));
|
||||
let (back, warnings, _) = envelope_back(&doc);
|
||||
assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}");
|
||||
assert!(matches!(back.kind, NodeKind::Group { .. }), "{back:#?}");
|
||||
}
|
||||
|
||||
/// An envelope with its contents in an opacity mask's art is written as its art there, and an
|
||||
/// envelope in a group stays live.
|
||||
#[test]
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
//! contents in an `X=` … `X+` block (while they are edited, the grid) as `AIDeformArtKey`, and the
|
||||
//! kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object); then the distorted art on plain
|
||||
//! lines, and the plugin's `XP` after it. A top object's outline is the edge of the grid written
|
||||
//! for it, one grid edge to each segment of its sides. An envelope the reader would open in another
|
||||
//! shape is written as its distorted art.
|
||||
//! for it, one grid edge to each segment of its sides, a side with fewer segments than the side
|
||||
//! across from it split into more. An envelope the reader would open in another shape is written as
|
||||
//! its distorted art.
|
||||
|
||||
use std::fmt::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Point, Vec2};
|
||||
use kurbo::{CubicBez, ParamCurve, Point, Vec2};
|
||||
use vectorcraft_doc::live::{
|
||||
Coons, EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, PreserveShape, WARP_NET, envelope_content_boxes,
|
||||
envelope_grid, envelope_stroke_margin, warp_grid,
|
||||
@@ -173,11 +174,44 @@ fn reads_as_written(rows: usize, cols: usize, pts: &[GridPoint]) -> bool {
|
||||
envelope_upright(&mesh).is_some_and(|(r, c, read)| (r as usize, c as usize) == (rows, cols) && read == pts)
|
||||
}
|
||||
|
||||
/// `pieces`, one side of a top object, split into `n` pieces: each piece is split into equal spans
|
||||
/// of its curve parameter, the extra pieces going one at a time to the piece with the longest
|
||||
/// control polygon for each of its spans. `None` where `n` is fewer pieces than it has, more than
|
||||
/// [`MAX_GRID`], or a split leaves a piece of no length.
|
||||
fn split_to(pieces: &[CubicBez], n: usize) -> Option<Vec<CubicBez>> {
|
||||
if n < pieces.len() || n > MAX_GRID {
|
||||
return None;
|
||||
}
|
||||
if n == pieces.len() {
|
||||
return Some(pieces.to_vec());
|
||||
}
|
||||
let polygon = |c: &CubicBez| c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3);
|
||||
let lengths: Vec<f64> = pieces.iter().map(polygon).collect();
|
||||
let mut parts = vec![1usize; pieces.len()];
|
||||
for _ in pieces.len()..n {
|
||||
let most = (0..pieces.len()).max_by(|a, b| (lengths[*a] / parts[*a] as f64).total_cmp(&(lengths[*b] / parts[*b] as f64)))?;
|
||||
parts[most] += 1;
|
||||
}
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for (c, p) in pieces.iter().zip(parts) {
|
||||
for j in 0..p {
|
||||
let piece = c.subsegment(j as f64 / p as f64..(j + 1) as f64 / p as f64);
|
||||
if [piece.p1, piece.p2, piece.p3].iter().all(|q| q.distance(piece.p0) <= 1e-9) {
|
||||
return None;
|
||||
}
|
||||
out.push(piece);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The grid a top object with outline `path` and corners `corners` is written with: its edge runs
|
||||
/// through the outline's anchors clockwise from the top left corner, one grid edge to a segment, so
|
||||
/// the top and bottom sides need as many segments as each other, as do the left and right sides;
|
||||
/// the points inside blend the sides. `None` where that grid's edge, read back as the top object's
|
||||
/// outline, doesn't give the same patch ([`Coons::from_top_object`]) at every place checked.
|
||||
/// through the outline's anchors clockwise from the top left corner, one grid edge to a segment,
|
||||
/// the points inside blending the sides. The top and bottom sides need as many grid edges as each
|
||||
/// other, as do the left and right sides, so where one side of a pair has fewer segments than the
|
||||
/// other, its segments are split ([`split_to`]) without changing its shape. `None` where that
|
||||
/// grid's edge, read back as the top object's outline, doesn't give the same patch
|
||||
/// ([`Coons::from_top_object`]) at every place checked.
|
||||
fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, usize, Vec<GridPoint>)> {
|
||||
let coons = Coons::from_top_object(path, corners)?;
|
||||
// The outline, as the patch takes it: the largest subpath.
|
||||
@@ -214,10 +248,35 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize,
|
||||
}
|
||||
};
|
||||
let gap = |k: usize| (at[(k + 1) % 4] + count - at[k]) % count;
|
||||
let (cols, rows) = (gap(0), gap(1));
|
||||
if gap(2) != cols || gap(3) != rows || cols > MAX_GRID || rows > MAX_GRID {
|
||||
if (0..4).any(|k| gap(k) > MAX_GRID) {
|
||||
return None;
|
||||
}
|
||||
// Each side's segments from its corner on, a straight one as a cubic with its handles a third of
|
||||
// the way along, which the patch spreads evenly along, as it does a straight segment.
|
||||
let side = |k: usize| -> Vec<CubicBez> {
|
||||
(0..gap(k))
|
||||
.map(|j| {
|
||||
let i = (at[k] + j) % count;
|
||||
let seg = sp.segment(i);
|
||||
if sp.segment_is_line(i) {
|
||||
CubicBez::new(seg.p0, seg.p0.lerp(seg.p3, 1.0 / 3.0), seg.p0.lerp(seg.p3, 2.0 / 3.0), seg.p3)
|
||||
} else {
|
||||
seg
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut sides = [side(0), side(1), side(2), side(3)];
|
||||
// Opposite sides take as many segments as each other: the one with fewer has some split.
|
||||
for k in 0..2 {
|
||||
let n = sides[k].len().max(sides[k + 2].len());
|
||||
for s in [k, k + 2] {
|
||||
sides[s] = split_to(&sides[s], n)?;
|
||||
}
|
||||
}
|
||||
let (cols, rows) = (sides[0].len(), sides[1].len());
|
||||
let edge: Vec<CubicBez> = sides.concat();
|
||||
let total = edge.len();
|
||||
let width = cols + 1;
|
||||
let mut pts: Vec<GridPoint> = vec![(Point::ZERO, [Vec2::ZERO; 4]); (rows + 1) * width];
|
||||
// Where the `k`th anchor from the top left corner goes, and the slots of the handles of the
|
||||
@@ -233,20 +292,15 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize,
|
||||
(rows - (k - 2 * cols - rows), 0, H_UP, H_DOWN)
|
||||
}
|
||||
};
|
||||
for k in 0..count {
|
||||
let i = (at[0] + k) % count;
|
||||
let seg = sp.segment(i);
|
||||
// A straight segment's patch spreads evenly along it, as a cubic's with its handles a third
|
||||
// of the way does.
|
||||
let (p1, p2) = if sp.segment_is_line(i) { (seg.p0.lerp(seg.p3, 1.0 / 3.0), seg.p0.lerp(seg.p3, 2.0 / 3.0)) } else { (seg.p1, seg.p2) };
|
||||
for (k, seg) in edge.iter().enumerate() {
|
||||
let (r, c, out, into) = place(k);
|
||||
let (nr, nc, _, _) = place((k + 1) % count);
|
||||
let (nr, nc, _, _) = place((k + 1) % total);
|
||||
if let Some(q) = pts.get_mut(r * width + c) {
|
||||
q.0 = seg.p0;
|
||||
q.1[out] = p1 - seg.p0;
|
||||
q.1[out] = seg.p1 - seg.p0;
|
||||
}
|
||||
if let Some(q) = pts.get_mut(nr * width + nc) {
|
||||
q.1[into] = p2 - seg.p3;
|
||||
q.1[into] = seg.p2 - seg.p3;
|
||||
}
|
||||
}
|
||||
// The points inside, blended from the sides.
|
||||
@@ -274,9 +328,20 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize,
|
||||
let read = Coons::from_top_object(&outline, Some(corners))?;
|
||||
let [tl, tr, br, bl] = coons.corners();
|
||||
let reach = tl.distance(br).max(tr.distance(bl)).max(1.0) * TOP_OBJECT_TOLERANCE;
|
||||
const STEPS: usize = 8;
|
||||
let at = |i: usize| i as f64 / STEPS as f64;
|
||||
let same = (0..=STEPS).all(|j| (0..=STEPS).all(|i| coons.eval(at(i), at(j)).distance(read.eval(at(i), at(j))) <= reach));
|
||||
// Each side of both patches runs straight between the places the sides have points
|
||||
// ([`Coons::places`]), so two matching sides lie furthest apart at one of those places. A point
|
||||
// inside a patch blends its four sides and corners, so where every side lies within a third of
|
||||
// `reach` of the other patch's, the patches lie within `reach` of each other everywhere.
|
||||
let merge = |a: Vec<f64>, b: Vec<f64>| {
|
||||
let mut v = [a, b].concat();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
let ([cu, cv], [ru, rv]) = (coons.places(), read.places());
|
||||
let (us, vs) = (merge(cu, ru), merge(cv, rv));
|
||||
let near = |u: f64, v: f64| coons.eval(u, v).distance(read.eval(u, v)) <= reach / 3.0;
|
||||
let same = us.iter().all(|&u| near(u, 0.0) && near(u, 1.0)) && vs.iter().all(|&v| near(0.0, v) && near(1.0, v));
|
||||
same.then_some((rows, cols, pts))
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
//! from those layers alone, without its page.
|
||||
//!
|
||||
//! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page
|
||||
//! of a PostScript file without one. A file whose program can't be read (an operator the
|
||||
//! of a PostScript file without one. A `.ai` in the legacy format ([`import_ai`]) that comes in
|
||||
//! through its layers keeps the artboard its header gives, with its art outside it; an EPS comes in
|
||||
//! on its page. A file whose program can't be read (an operator the
|
||||
//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in from its
|
||||
//! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not
|
||||
//! included, as other apps write it), or with its art on one layer when it is in the legacy format
|
||||
@@ -279,7 +281,20 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result<
|
||||
/// and data sets) read in `names`. The type of the document it gives that is set in families that
|
||||
/// aren't available shows in the fallback font, and a warning names them.
|
||||
pub fn import_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result<Imported, String> {
|
||||
let mut r = read_names(bytes, editing_data, trust, names)?;
|
||||
read_checked(bytes, editing_data, trust, names, false)
|
||||
}
|
||||
|
||||
/// [`import_names`] for a `.ai` (or `.ait`) in PostScript form: a program in the legacy format
|
||||
/// that comes in through its layers keeps the artboard its header gives, with its art outside it,
|
||||
/// where an EPS comes in on its page, its art's box. A `.ai` of versions 3 and 4, which doesn't
|
||||
/// give its artboard's size, has a US Letter artboard around its template box's center.
|
||||
pub fn import_ai(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result<Imported, String> {
|
||||
read_checked(bytes, editing_data, trust, names, true)
|
||||
}
|
||||
|
||||
/// [`import_names`] (a `.ai` when `ai_file`, see [`import_ai`]).
|
||||
fn read_checked(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding, ai_file: bool) -> Result<Imported, String> {
|
||||
let mut r = read_names(bytes, editing_data, trust, names, ai_file)?;
|
||||
let missing = missing_fonts(&r.document);
|
||||
if !missing.is_empty() {
|
||||
r.warnings.push(format!("{MISSING_FONTS_NOTE}: {}", missing.join(", ")));
|
||||
@@ -314,8 +329,8 @@ fn missing_fonts(d: &Document) -> Vec<String> {
|
||||
missing
|
||||
}
|
||||
|
||||
/// [`import_names`] before the type's fonts are checked.
|
||||
fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result<Imported, String> {
|
||||
/// [`import_names`] before the type's fonts are checked (a `.ai` when `ai_file`, see [`import_ai`]).
|
||||
fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding, ai_file: bool) -> Result<Imported, String> {
|
||||
let ps = program(bytes)?;
|
||||
let dsc = Dsc::read(ps);
|
||||
// A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no
|
||||
@@ -323,7 +338,7 @@ fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding
|
||||
// printed).
|
||||
if editing_data
|
||||
&& dsc.own_legacy
|
||||
&& let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator, names)
|
||||
&& let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator, ai_file, names)
|
||||
{
|
||||
return Ok(Imported { document, warnings, preview: false });
|
||||
}
|
||||
@@ -343,7 +358,7 @@ fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding
|
||||
}
|
||||
let drew = !out.drawn.is_empty();
|
||||
let why = match (&result, drew) {
|
||||
(Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, dsc.illustrator, names)),
|
||||
(Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, dsc.illustrator, ai_file, names)),
|
||||
(Ok(()), true) => return Ok(finish(out)),
|
||||
(Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(),
|
||||
(Err(e), _) => reason(e, fault.as_ref()),
|
||||
@@ -351,7 +366,7 @@ fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding
|
||||
// A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the
|
||||
// prolog defining its operators without including it): its layers, which the program has, or
|
||||
// its art on one layer when it has none (Illustrator 3).
|
||||
let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator, names)).flatten() {
|
||||
let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator, ai_file, names)).flatten() {
|
||||
Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }),
|
||||
Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"),
|
||||
None => why,
|
||||
|
||||
@@ -55,8 +55,8 @@ pub use read::{
|
||||
palette, read_beside, read_in, symbols, variables,
|
||||
};
|
||||
pub(crate) use read::{
|
||||
MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with,
|
||||
symbol_art_member,
|
||||
MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_program, read_style_list, slot_frame, slot_of,
|
||||
style_moves_with, symbol_art_member,
|
||||
};
|
||||
|
||||
pub(crate) use read::is_look_slot;
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
//! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`.
|
||||
//! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id.
|
||||
//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, with each one's display
|
||||
//! options, else `%AI3_Cropmarks`, else the art size around the template box's centre
|
||||
//! (`%AI3_TemplateBox`, `%%TemplateBox` in older files), else (a file in the legacy format
|
||||
//! another app wrote) the bounding box.
|
||||
//! options, else `%AI3_Cropmarks`, else the art size (`%AI5_ArtSize`; VectorCraft's own default,
|
||||
//! US Letter, in a `.ai` of versions 3 and 4, which don't give it) around the template box's center (`%AI3_TemplateBox`,
|
||||
//! `%%TemplateBox` in older files), which the version 7 specification (§2.2) puts at the
|
||||
//! artboard's center, else (a file in the legacy format another app wrote) the bounding box.
|
||||
//! - Document settings: Document Raster Effects Settings from the `/Document` dictionary's
|
||||
//! `AI Auto Rasterize`; the author from the header's `%%For`; swatches for the custom colors of
|
||||
//! `%%CMYKCustomColor` and `%%RGBCustomColor` that the document has none of.
|
||||
@@ -102,8 +103,10 @@
|
||||
//! in the Symbols panel's order. An instance: `/SymbolInstance : (name) /SymbolRef , a b c d tx
|
||||
//! ty /RTransformMatrix ;`, the matrix taking the symbol's art to art space; in a symbol's art,
|
||||
//! it takes it to art space about the canvas point of art space's origin, as a pattern's
|
||||
//! gradients are. Its hide and lock come before it (`Xw`, `A`) and its transparency (`Xy`) after
|
||||
//! it.
|
||||
//! gradients are. Files of versions 10 to 13 write `a b c d tx ty /TransformMatrix` instead,
|
||||
//! the matrix taking the symbol's art to the canvas (y down, art space's origin at the canvas
|
||||
//! point the template box gives); in a symbol's art, it takes the art to that symbol's space.
|
||||
//! Its hide and lock come before it (`Xw`, `A`) and its transparency (`Xy`) after it.
|
||||
//!
|
||||
//! Sections the reader doesn't need (styles, filters) are skipped; type, instances of symbols it
|
||||
//! can't read, fills with a pattern it can't read, placed files without a preview and operators
|
||||
@@ -271,12 +274,28 @@ pub(crate) fn read_over_page(data: &[u8], large_canvas: bool, names: NameEncodin
|
||||
read_with(data, large_canvas, true, names)
|
||||
}
|
||||
|
||||
fn read_with(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncoding) -> Result<Structure, String> {
|
||||
/// [`read_in`] (or [`read_over_page`] when `page_art`) for the program of a `.ai` in the legacy
|
||||
/// format: one of versions 3 and 4 that has a template box but no art size has a US Letter
|
||||
/// artboard ([`Reader::sized_board`]).
|
||||
pub(crate) fn read_program(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncoding) -> Result<Structure, String> {
|
||||
let mut r = Reader::with_names(data, names);
|
||||
r.large_canvas = large_canvas.then_some(1.0);
|
||||
r.page_art = page_art;
|
||||
r.run()?;
|
||||
r.finish()
|
||||
r.legacy_ai = true;
|
||||
r.read_as(large_canvas, page_art)
|
||||
}
|
||||
|
||||
fn read_with(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncoding) -> Result<Structure, String> {
|
||||
Reader::with_names(data, names).read_as(large_canvas, page_art)
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// The structure read, the declared large canvas scale applied when `large_canvas`, the page's
|
||||
/// art replacing the objects not read when `page_art` ([`read_over_page`]).
|
||||
fn read_as(mut self, large_canvas: bool, page_art: bool) -> Result<Structure, String> {
|
||||
self.large_canvas = large_canvas.then_some(1.0);
|
||||
self.page_art = page_art;
|
||||
self.run()?;
|
||||
self.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// How many objects [`read`] counts against [`MAX_NODES`] in the editing data `data`.
|
||||
@@ -643,6 +662,11 @@ struct Reader<'a> {
|
||||
/// The artboard's size and centre in files without artboards or crop marks.
|
||||
art_size: Option<(f64, f64)>,
|
||||
template_center: Option<(f64, f64)>,
|
||||
/// The data is the program of a `.ai` in the legacy format ([`read_program`]), not an EPS's:
|
||||
/// without an art size, its artboard can be US Letter ([`Reader::sized_board`]).
|
||||
legacy_ai: bool,
|
||||
/// The header has `%AI5_FileFormat`, which files of version 5 and later write.
|
||||
file_format: bool,
|
||||
/// `%AI24_LargeCanvasScale`: a Large Canvas document's editing data is written at 1/N scale
|
||||
/// (see [`Reader::ensure_space`]); 1 (no scaling) when the file doesn't declare one, `None`
|
||||
/// when the declared factor is not applied ([`read`]).
|
||||
@@ -825,6 +849,8 @@ impl<'a> Reader<'a> {
|
||||
xml_id_bytes: 0,
|
||||
art_size: None,
|
||||
template_center: None,
|
||||
legacy_ai: false,
|
||||
file_format: false,
|
||||
large_canvas: None,
|
||||
page_art: false,
|
||||
canvas_scale: 1.0,
|
||||
|
||||
@@ -636,17 +636,22 @@ impl<'a> Reader<'a> {
|
||||
/// A symbol instance (`/SymbolInstance`): the symbol its `SymbolRef` names, placed by its
|
||||
/// `RTransformMatrix`, hidden and locked as the state says. In a symbol's art the matrix is
|
||||
/// written about the canvas point of art space's origin, and the name is the file's until the
|
||||
/// symbols are checked ([`Self::check_symbols`]). An instance of a symbol that wasn't read, one
|
||||
/// whose appearance differs from its symbol's, or one placed only by a `Matrix` or `Transform`,
|
||||
/// comes in as the page draws it, within the symbol's box placed by the matrix when the box is
|
||||
/// known.
|
||||
/// symbols are checked ([`Self::check_symbols`]). Files of versions 10 to 13 place an instance
|
||||
/// by a `TransformMatrix` instead, which takes the symbol's art to the canvas (y down); in a
|
||||
/// symbol's art, it takes the art to that symbol's space. The reader converts it with the canvas
|
||||
/// point of art space's origin, from the template box. An instance of a symbol that wasn't
|
||||
/// read, one whose appearance differs from its symbol's, or one placed only by a `Matrix` or
|
||||
/// `Transform`, comes in as the page draws it, within the symbol's box placed by the matrix
|
||||
/// when the box is known.
|
||||
fn symbol_instance(&mut self, o: &Obj) -> Result<(), String> {
|
||||
let name = self.names.entry(o, "SymbolRef");
|
||||
let affine = |n: &[f64]| match n {
|
||||
[a, b, c, d, e, f] if [a, b, c, d, e, f].iter().all(|v| v.is_finite()) => Some(super::Affine::new([*a, *b, *c, *d, *e, *f])),
|
||||
_ => None,
|
||||
};
|
||||
let live = affine(&o.nums("RTransformMatrix"));
|
||||
// The canvas → art space: y turned up about the canvas point of art space's origin.
|
||||
let from_canvas = self.canvas_origin().map(|o| super::Affine::new([1.0, 0.0, 0.0, -1.0, -o.x, o.y]));
|
||||
let live = affine(&o.nums("RTransformMatrix")).or_else(|| Some(from_canvas? * affine(&o.nums("TransformMatrix"))?));
|
||||
let boxed_only = live.is_none();
|
||||
let matrix = live.or_else(|| {
|
||||
o.get("Matrix")
|
||||
|
||||
@@ -242,10 +242,13 @@ impl<'a> Reader<'a> {
|
||||
&& let Ok(n) = v.trim().parse::<u8>()
|
||||
{
|
||||
self.ruler_units = Some(n);
|
||||
} else if self.frames.is_empty() && line.starts_with("AI5_FileFormat") {
|
||||
self.file_format = true;
|
||||
} else if let Some(v) = line.strip_prefix("AI5_ArtSize:") {
|
||||
self.art_size = two(v);
|
||||
} else if let Some(v) = line.strip_prefix("AI3_TemplateBox:").or_else(|| line.strip_prefix("%TemplateBox:")) {
|
||||
self.template_center = two(v);
|
||||
// The center of the box (a file without a template writes it with no width or height).
|
||||
self.template_center = four(v).map(|[x0, y0, x1, y1]| ((x0 + x1) / 2.0, (y0 + y1) / 2.0)).or_else(|| two(v));
|
||||
self.template = four(v).or(self.template);
|
||||
} else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") {
|
||||
self.large_canvas_scale(v);
|
||||
|
||||
@@ -871,9 +871,15 @@ impl<'a> Reader<'a> {
|
||||
// ---- the document ----------------------------------------------------------------------
|
||||
|
||||
/// The artboard of a file without artboards or crop marks: its size around the template
|
||||
/// box's centre (half a point off the grid).
|
||||
/// box's center (half a point off the grid). The program of a `.ai` of versions 3 and 4
|
||||
/// ([`Reader::legacy_ai`], no `%AI5_FileFormat`), which don't record the artboard's size, has
|
||||
/// VectorCraft's own default there, a US Letter artboard (612 × 792 pt); an EPS, or a newer
|
||||
/// file without `%AI5_ArtSize`, has none (its page is its bounding box).
|
||||
fn sized_board(&self) -> Option<Rect> {
|
||||
let ((w, h), (cx, cy)) = (self.art_size?, self.template_center?);
|
||||
let (cx, cy) = self.template_center?;
|
||||
let [_, _, letter_w, letter_h] = crate::import::LETTER;
|
||||
let letter = self.legacy_ai && !self.file_format;
|
||||
let (w, h) = self.art_size.or(letter.then_some((letter_w, letter_h)))?;
|
||||
// Rounded out, but not off a whole point the decimals in the file only just miss.
|
||||
let near = |v: f64| (v - v.round()).abs() < 1e-6;
|
||||
let (left, top) = (cx - w / 2.0, cy + h / 2.0);
|
||||
|
||||
@@ -5115,6 +5115,73 @@ fn an_instance_in_a_symbols_art_is_placed_from_the_canvas() {
|
||||
assert_eq!(instance_box(&doc, &a[0]), Rect::new(58.0, 53.0, 62.0, 57.0));
|
||||
}
|
||||
|
||||
/// Files of versions 10 to 13 place an instance by a `TransformMatrix` that takes the symbol's art
|
||||
/// to the canvas (y down): the instance stays live and lands where the `RTransformMatrix` of a
|
||||
/// newer file puts it, on the page and in a symbol's art.
|
||||
#[test]
|
||||
fn an_instance_placed_on_the_canvas_by_an_older_file_stays_live() {
|
||||
// The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242).
|
||||
let older = |name: &str, m: [f64; 6]| {
|
||||
let [a, b, c, d, e, f] = m;
|
||||
format!("/SymbolInstance :\n({name}) /SymbolRef ,\n{a} {b} {c} {d} {e} {f} /TransformMatrix\n;\n")
|
||||
};
|
||||
let page = older("Dot", [1.0, 0.0, 0.0, 1.0, 8111.0, 8192.0]) + &older("Dot", [2.0, 0.0, 0.0, 2.0, 8191.0, 8192.0]);
|
||||
let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{}{}", symbols(&symbol("Dot", &dot(), 5)), layer(&page));
|
||||
let doc = read_ok(&body).doc;
|
||||
let a = art(&doc);
|
||||
assert_eq!(a.len(), 2);
|
||||
assert!(a.iter().all(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })), "{a:#?}");
|
||||
assert_eq!(instance_box(&doc, &a[0]), Rect::new(18.0, 48.0, 22.0, 52.0));
|
||||
assert_eq!(instance_box(&doc, &a[1]), Rect::new(96.0, 46.0, 104.0, 54.0));
|
||||
// Turned and scaled, as the newer file writes the same instance: canvas x is art x + 8091 and
|
||||
// canvas y is 8242 - art y, so the second row of the matrix changes sign.
|
||||
let turned = [1.299, -0.4, 0.75, 0.6928, 8111.0, 8192.0];
|
||||
let newer = [1.299, 0.4, 0.75, -0.6928, 20.0, 50.0];
|
||||
let older_doc =
|
||||
read_ok(&format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{}{}", symbols(&symbol("Dot", &dot(), 5)), layer(&older("Dot", turned)))).doc;
|
||||
let newer_doc =
|
||||
read_ok(&format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{}{}", symbols(&symbol("Dot", &dot(), 5)), layer(&instance("Dot", newer)))).doc;
|
||||
let xf = |d: &Document| match &art(d)[0].kind {
|
||||
NodeKind::SymbolInstance { xf, .. } => xf.as_coeffs(),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let (o, n) = (xf(&older_doc), xf(&newer_doc));
|
||||
assert!(o.iter().zip(n).all(|(a, b)| (a - b).abs() < 1e-9), "{o:?} {n:?}");
|
||||
// In a symbol's art the matrix takes the art to the symbol's own space: Dot at (10, 5) in Pair's
|
||||
// art, which the page instance puts at (50, 50).
|
||||
let nested = older("Dot", [1.0, 0.0, 0.0, 1.0, 10.0, 5.0]);
|
||||
let defs = symbols(&(symbol("Dot", &dot(), 5) + &symbol("Pair", &nested, 1)));
|
||||
let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{defs}{}", layer(&older("Pair", [1.0, 0.0, 0.0, 1.0, 8141.0, 8192.0])));
|
||||
let doc = read_ok(&body).doc;
|
||||
let a = art(&doc);
|
||||
assert_eq!(instance_box(&doc, &a[0]), Rect::new(58.0, 53.0, 62.0, 57.0));
|
||||
// One document saved in both forms: the template box's center is (100.5, 99.5), so art space's
|
||||
// origin is at canvas (8091, 8291). Dot sits at (5, 2.5) in Pair's art, and the page instance
|
||||
// puts Pair at (57, 145.5). Both instances get the newer file's matrices, coefficient by
|
||||
// coefficient.
|
||||
let saved = |inner: String, pair: String| {
|
||||
let defs = symbols(&(symbol("Dot", &dot(), 5) + &symbol("Pair", &inner, 1)));
|
||||
read_ok(&format!("%AI3_TemplateBox: 100.5 99.5 100.5 99.5\n{defs}{}", layer(&pair))).doc
|
||||
};
|
||||
let older_doc = saved(older("Dot", [1.0, 0.0, 0.0, 1.0, 5.0, 2.5]), older("Pair", [1.0, 0.0, 0.0, 1.0, 8148.0, 8145.5]));
|
||||
let newer_doc = saved(instance("Dot", [1.0, 0.0, 0.0, -1.0, -8086.0, 8288.5]), instance("Pair", [1.0, 0.0, 0.0, -1.0, 57.0, 145.5]));
|
||||
fn first_xf(n: &Node) -> Option<[f64; 6]> {
|
||||
match &n.kind {
|
||||
NodeKind::SymbolInstance { xf, .. } => Some(xf.as_coeffs()),
|
||||
_ => n.children()?.iter().find_map(|c| first_xf(c)),
|
||||
}
|
||||
}
|
||||
let both = |d: &Document| (xf(d), first_xf(&d.symbols[1].art).expect("Dot's instance in Pair's art"));
|
||||
let ((op, on), (np, nn)) = (both(&older_doc), both(&newer_doc));
|
||||
assert!(op.iter().zip(np).all(|(a, b)| (a - b).abs() < 1e-9), "{op:?} {np:?}");
|
||||
assert!(on.iter().zip(nn).all(|(a, b)| (a - b).abs() < 1e-9), "{on:?} {nn:?}");
|
||||
// Without a template box the canvas's place isn't known: the instance isn't read, though Dot is
|
||||
// defined and the same instance with a template box is read above.
|
||||
let defs = symbols(&symbol("Dot", &dot(), 5));
|
||||
let e = read(stream(&format!("{defs}{}", layer(&older("Dot", [1.0, 0.0, 0.0, 1.0, 8111.0, 8192.0])))).as_bytes());
|
||||
assert!(e.is_err_and(|e| e.contains("symbols")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_in_a_symbols_art_is_placed_from_the_canvas() {
|
||||
// The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242). The
|
||||
|
||||
@@ -19,6 +19,16 @@ pub(super) enum Page {
|
||||
Art,
|
||||
/// A `.ai` is read on its (first and only) artboard.
|
||||
Artboard,
|
||||
/// A `.ai` in the legacy format (PostScript, not an EPS): its page is its art's bounding box, as
|
||||
/// an EPS's is, and its artboard is the one its header gives.
|
||||
Program,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
/// Is the page the art's bounding box?
|
||||
fn of_art(self) -> bool {
|
||||
matches!(self, Page::Art | Page::Program)
|
||||
}
|
||||
}
|
||||
|
||||
/// How art moved from a page to its place in the layers' document transforms: its pattern tiles
|
||||
@@ -62,8 +72,12 @@ pub(super) fn build(
|
||||
) -> Result<(Document, Vec<String>), String> {
|
||||
// An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art
|
||||
// space, as its PDF part may (#2031).
|
||||
let large_canvas = matches!(page, Page::Art);
|
||||
let structure = if visible.is_some() { ai::read_over_page(data, large_canvas, names)? } else { ai::read_in(data, large_canvas, names)? };
|
||||
let large_canvas = page.of_art();
|
||||
let structure = match page {
|
||||
Page::Program => ai::read_program(data, large_canvas, visible.is_some(), names)?,
|
||||
_ if visible.is_some() => ai::read_over_page(data, large_canvas, names)?,
|
||||
_ => ai::read_in(data, large_canvas, names)?,
|
||||
};
|
||||
build_from(structure, data, visible, page, outlined, trust, unit)
|
||||
}
|
||||
|
||||
@@ -184,7 +198,7 @@ fn page_scale_alone(structure: &Structure, pages: &[(f64, f64)], unit: f64) -> O
|
||||
/// page, which draws nothing for them, the linked images' `placeholders`. A PDF part draws those
|
||||
/// images' pixels, which the placeholders don't match.
|
||||
fn left_out_of_comparisons(page: Page, mut restyled: Vec<Rect>, placeholders: Vec<Rect>) -> Vec<Rect> {
|
||||
if matches!(page, Page::Art) {
|
||||
if page.of_art() {
|
||||
restyled.extend(placeholders);
|
||||
}
|
||||
restyled
|
||||
@@ -295,7 +309,7 @@ pub(super) fn build_weighing(
|
||||
let mut frame = None;
|
||||
if let Some(visible) = visible {
|
||||
let art_box = match page {
|
||||
Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?,
|
||||
Page::Art | Page::Program => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?,
|
||||
Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?,
|
||||
};
|
||||
let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?;
|
||||
@@ -393,8 +407,9 @@ pub(super) fn build_weighing(
|
||||
// An EPS saved as its artboard has the art outside it left out of its page.
|
||||
let artboard_page =
|
||||
artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0));
|
||||
let whole = matches!(page, Page::Art) && !artboard_page;
|
||||
if whole {
|
||||
let whole = page.of_art() && !artboard_page;
|
||||
// A `.ai` keeps the artboard its editing copy gives, with the art outside it.
|
||||
if whole && matches!(page, Page::Art) {
|
||||
// Its page is the file's own (its bounding box), as when it comes in as its page: art
|
||||
// reaching past the editing data's artboard stays on it (#1102).
|
||||
doc.artboards.truncate(1);
|
||||
@@ -1815,7 +1830,7 @@ fn last_boards(boards: &[vectorcraft_doc::Artboard], pages: &[Rect]) -> Option<V
|
||||
/// the one whose art draws most like the page, the first of them on a tie. Else (and for an EPS,
|
||||
/// whose page is its art) the first artboard (0).
|
||||
fn shown_artboard(doc: &Document, visible: &Document, page: Page, page_rect: Rect, fits: impl Fn(&Rect) -> bool) -> usize {
|
||||
if matches!(page, Page::Art) || visible.artboards.len() >= doc.artboards.len() {
|
||||
if page.of_art() || visible.artboards.len() >= doc.artboards.len() {
|
||||
return 0;
|
||||
}
|
||||
let pages: Vec<Rect> = visible.artboards.iter().map(|a| a.rect).collect();
|
||||
|
||||
@@ -13,12 +13,16 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect
|
||||
/// ([`use_symbols`]) and its variable library ([`use_variables`]) when it could be read, and with
|
||||
/// its layer tree, empty, below the page's layer when that could be read (#2033). A program in the
|
||||
/// legacy format without layers (`illustrator`, see [`program_alone`]) is read with its art on one
|
||||
/// layer; when that can't be used, the file is `visible` as it was. `names`: the encoding of names
|
||||
/// that aren't UTF-8.
|
||||
pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool, names: ai::NameEncoding) -> Imported {
|
||||
/// layer; when that can't be used, the file is `visible` as it was. The program of a `.ai`
|
||||
/// (`ai_file`) in the legacy format keeps the artboard its header gives ([`Page::Program`]).
|
||||
/// `names`: the encoding of names that aren't UTF-8.
|
||||
pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool, ai_file: bool, names: ai::NameEncoding) -> Imported {
|
||||
let program_page = if ai_file { Page::Program } else { Page::Art };
|
||||
let Some(data) = editing_copy(ps) else {
|
||||
let read =
|
||||
illustrator.then(|| one_layer(ps)).flatten().and_then(|p| build(&p, Some(&visible.document), Page::Art, false, trust, 1.0, names).ok());
|
||||
let read = illustrator
|
||||
.then(|| one_layer(ps))
|
||||
.flatten()
|
||||
.and_then(|p| build(&p, Some(&visible.document), program_page, false, trust, 1.0, names).ok());
|
||||
return match read {
|
||||
Some((document, warnings)) => with_page_notes(document, warnings, &visible),
|
||||
None => visible,
|
||||
@@ -28,11 +32,17 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus
|
||||
// can't be used.
|
||||
// Private data kept apart from the program: the program's own header gives the author and
|
||||
// custom colors the private data's header may lack.
|
||||
let read =
|
||||
|d: &[u8]| if matches!(data, Ok(Cow::Owned(_))) { ai::read_beside(d, true, ps, true, names) } else { ai::read_over_page(d, true, names) };
|
||||
let private = matches!(data, Ok(Cow::Owned(_)));
|
||||
let read = |d: &[u8]| match (private, program_page) {
|
||||
(true, _) => ai::read_beside(d, true, ps, true, names),
|
||||
(false, Page::Program) => ai::read_program(d, true, true, names),
|
||||
(false, _) => ai::read_over_page(d, true, names),
|
||||
};
|
||||
// Private data is an EPS's editing copy, whatever the file is named.
|
||||
let page = if private { Page::Art } else { program_page };
|
||||
let (tree, result) = match data.as_deref() {
|
||||
Ok(d) => match read(d) {
|
||||
Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), Page::Art, false, trust, 1.0)),
|
||||
Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), page, false, trust, 1.0)),
|
||||
Err(why) => (vec![], Err(why)),
|
||||
},
|
||||
Err(why) => (vec![], Err(why.clone())),
|
||||
@@ -607,16 +617,23 @@ fn editing_copy(ps: &[u8]) -> Option<Result<Cow<'_, [u8]>, String>> {
|
||||
/// but whose program has no layers (Illustrator 3 had none; image editors still export their
|
||||
/// paths so, storytold/photocraft#6036) is read with its art on one layer. `None` when the program
|
||||
/// has no layers and isn't in the format: other PostScript isn't read as the format's operators.
|
||||
/// The program of a `.ai` (`ai_file`) has the artboard its header gives ([`Page::Program`]).
|
||||
/// `names`: the encoding of names that aren't UTF-8.
|
||||
pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool, names: ai::NameEncoding) -> Option<Result<(Document, Vec<String>), String>> {
|
||||
pub(in super::super) fn program_alone(
|
||||
ps: &[u8],
|
||||
illustrator: bool,
|
||||
ai_file: bool,
|
||||
names: ai::NameEncoding,
|
||||
) -> Option<Result<(Document, Vec<String>), String>> {
|
||||
if ai::has_eps_data(ps) {
|
||||
return None;
|
||||
}
|
||||
let page = if ai_file { Page::Program } else { Page::Art };
|
||||
if has_layers(ps) {
|
||||
return Some(build(ps, None, Page::Art, false, false, 1.0, names));
|
||||
return Some(build(ps, None, page, false, false, 1.0, names));
|
||||
}
|
||||
let program = illustrator.then(|| one_layer(ps)).flatten()?;
|
||||
Some(build(&program, None, Page::Art, false, false, 1.0, names))
|
||||
Some(build(&program, None, page, false, false, 1.0, names))
|
||||
}
|
||||
|
||||
/// `ps` with its art (from its `%%EndSetup` to its `%%PageTrailer`, else its `%%Trailer`, else its
|
||||
|
||||
@@ -9,7 +9,7 @@ use vectorcraft_doc::clipnest::MAX_NEST;
|
||||
use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::import::import;
|
||||
use crate::import::{import, import_ai};
|
||||
|
||||
/// A minimal file in Illustrator's format: its header comments, a prolog of our own defining the
|
||||
/// operators `body` uses (`u` and `U` as `ugroup` and `Ugroup`), and `body`.
|
||||
@@ -329,6 +329,100 @@ fn a_legacy_file_with_its_prolog_comes_in_through_its_layers() {
|
||||
assert_eq!(page.document.layers[0].name.as_deref(), Some("Layer 1"));
|
||||
}
|
||||
|
||||
/// A `.ai` in the legacy format (version 8, `%AI5_FileFormat 4.0`) whose prolog is included: a
|
||||
/// square on its 200-point artboard and one far to its right. Its bounding box is the box of that
|
||||
/// art; its artboard is its art size around the template box's center (version 7 specification
|
||||
/// §2.2). Its first line names the EPSF version, as the format's files may.
|
||||
const LEGACY_AI_ART_OFF_ITS_ARTBOARD: &str = "%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: a script\n%%BoundingBox: 10 10 320 30\n\
|
||||
%%HiResBoundingBox: 10 10 320 30\n%AI5_FileFormat 4.0\n%AI3_TemplateBox: 100.5 99.5 100.5 99.5\n%AI5_ArtSize: 200 200\n\
|
||||
%%EndComments\n%%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def\n\
|
||||
/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def /A {pop} def /k {setcmykcolor} def\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n\
|
||||
%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Art) Ln\n0 A\n0 0 0 1 k\n\
|
||||
10 10 m\n30 10 L\n30 30 L\n10 30 L\nf\n300 10 m\n320 10 L\n320 30 L\n300 30 L\nf\nLB\n%AI5_EndLayer--\n\
|
||||
%%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n";
|
||||
|
||||
/// `bytes` opened as a `.ai`.
|
||||
fn open_ai(bytes: &[u8]) -> crate::Imported {
|
||||
import_ai(bytes, true, false, crate::NameEncoding::default()).unwrap()
|
||||
}
|
||||
|
||||
/// Such a `.ai` keeps the artboard its header gives, with the square outside it, when it comes in
|
||||
/// through its layers. The same program opened as an EPS comes in on its page, its art's box
|
||||
/// (#1102).
|
||||
#[test]
|
||||
fn a_legacy_ai_keeps_its_artboard_and_the_art_outside_it() {
|
||||
let r = open_ai(LEGACY_AI_ART_OFF_ITS_ARTBOARD.as_bytes());
|
||||
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
|
||||
let d = &r.document;
|
||||
let boards: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(boards, [Rect::new(0.0, 0.0, 200.0, 200.0)]);
|
||||
let art: Vec<_> = d.layers[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap()).collect();
|
||||
// Art space has y up from the artboard's bottom: (10, 10) is 10 above it.
|
||||
assert_eq!(art, [Rect::new(10.0, 170.0, 30.0, 190.0), Rect::new(300.0, 170.0, 320.0, 190.0)]);
|
||||
let eps = import(LEGACY_AI_ART_OFF_ITS_ARTBOARD.as_bytes()).unwrap();
|
||||
let boards: Vec<_> = eps.document.artboards.iter().map(|a| a.rect.size()).collect();
|
||||
assert_eq!(boards, [kurbo::Size::new(310.0, 20.0)], "{:?}", eps.warnings);
|
||||
}
|
||||
|
||||
/// A version 3 `.ai` doesn't give its artboard's size (`%AI5_ArtSize` came with version 5): its
|
||||
/// artboard is VectorCraft's own default, a US Letter page, around the template box's center, the
|
||||
/// artboard's center (version 7 specification §2.2). Without a template box it is the art's box,
|
||||
/// and so is the page of the same file opened as an EPS.
|
||||
#[test]
|
||||
fn a_version_3_ai_has_a_letter_artboard_around_its_template_boxs_center() {
|
||||
let v3 = PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "%AI3_TemplateBox: 100 100 100 100\n");
|
||||
let r = open_ai(v3.as_bytes());
|
||||
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
|
||||
let d = &r.document;
|
||||
let boards: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(boards, [Rect::new(0.0, 0.0, 612.0, 792.0)]);
|
||||
// The artboard runs from -206 to 406 across and from -296 to 496 up in art space.
|
||||
let art = d.layers[0].children().unwrap();
|
||||
assert_eq!(art[1].geometric_bounds(), Some(Rect::new(226.0, 446.0, 266.0, 476.0)));
|
||||
// The center of a template box with a size (a file with a template image).
|
||||
let r = open_ai(PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "%AI3_TemplateBox: 50 50 150 150\n").as_bytes());
|
||||
assert_eq!(r.document.layers[0].children().unwrap()[1].geometric_bounds(), Some(Rect::new(226.0, 446.0, 266.0, 476.0)));
|
||||
let r = open_ai(PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "%AI3_ColorUsage: Color\n").as_bytes());
|
||||
assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "{:?}", r.warnings);
|
||||
let r = import(v3.as_bytes()).unwrap();
|
||||
assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "{:?}", r.warnings);
|
||||
// A newer file (`%AI5_FileFormat`, version 5 on) without an art size keeps the art's box too.
|
||||
let r = open_ai(v3.replace("%%EndComments", "%AI5_FileFormat 1.0\n%%EndComments").as_bytes());
|
||||
assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A version 3 `.ai` whose prolog is included and whose program has no layers: its page draws, and
|
||||
/// its art comes in on one layer around a US Letter artboard, the square far to the right outside
|
||||
/// it.
|
||||
#[test]
|
||||
fn a_version_3_ai_that_draws_keeps_its_letter_artboard_and_the_art_outside_it() {
|
||||
let v3 = "%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: a script\n%%BoundingBox: 10 10 720 30\n%AI3_TemplateBox: 100 100 100 100\n\
|
||||
%%EndComments\n%%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def /k {setcmykcolor} def\n\
|
||||
%%EndProlog\n%%BeginSetup\n%%EndSetup\n0 0 0 1 k\n10 10 m\n30 10 L\n30 30 L\n10 30 L\nf\n\
|
||||
700 10 m\n720 10 L\n720 30 L\n700 30 L\nf\n%%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n";
|
||||
let r = open_ai(v3.as_bytes());
|
||||
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
|
||||
let boards: Vec<_> = r.document.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(boards, [Rect::new(0.0, 0.0, 612.0, 792.0)]);
|
||||
assert_eq!(r.document.layers.len(), 1);
|
||||
let art: Vec<_> = r.document.layers[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap()).collect();
|
||||
// The artboard runs from -206 to 406 across and from -296 to 496 up in art space.
|
||||
assert_eq!(art, [Rect::new(216.0, 466.0, 236.0, 486.0), Rect::new(906.0, 466.0, 926.0, 486.0)]);
|
||||
let eps = import(v3.as_bytes()).unwrap();
|
||||
assert_eq!(eps.document.artboards[0].rect, Rect::new(0.0, 0.0, 710.0, 20.0), "{:?}", eps.warnings);
|
||||
}
|
||||
|
||||
/// A `.ai` another app wrote (#1027) that gives a template box but no art size opens on a US Letter
|
||||
/// artboard around the box's center, from -18 to 594 across and -12 to 780 up in art space (as an
|
||||
/// EPS it opens on its bounding box, see above).
|
||||
#[test]
|
||||
fn a_postscript_ai_with_a_template_box_and_no_art_size_opens_on_a_letter_artboard() {
|
||||
let r = open_ai(NAMED_PROLOG.as_bytes());
|
||||
assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0), "{:?}", r.warnings);
|
||||
let square = &r.document.layers[0].children().unwrap()[0];
|
||||
assert_eq!(square.geometric_bounds(), Some(Rect::new(28.0, 690.0, 108.0, 770.0)));
|
||||
}
|
||||
|
||||
/// A placed EPS in the legacy format (version 7 specification §16, no `%AI5_BeginPlace`), its file
|
||||
/// included whole: the layers come in, the placed file as the art the page draws in its box.
|
||||
#[test]
|
||||
|
||||
@@ -2197,6 +2197,34 @@ fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
|
||||
assert!(!d.layers[2].visible);
|
||||
}
|
||||
|
||||
/// A `.ai` of versions 10 to 13, whose symbol instances are placed by a `TransformMatrix` on the
|
||||
/// canvas, opens from its layers: the art outside its artboard, an instance among it, comes in,
|
||||
/// rather than the PDF part alone, which has none of it.
|
||||
#[test]
|
||||
fn an_older_ai_files_symbols_keep_its_layers_and_the_art_outside_its_artboard() {
|
||||
// The template box's center is (50.5, 50.5): art space's origin is at canvas (8141, 8242). The
|
||||
// instance puts Box at (150, 50) in art space, right of the artboard (x 0 to 100).
|
||||
let instance = "/SymbolInstance :\n(Box) /SymbolRef ,\n1 0 0 1 8291 8192 /TransformMatrix\n;\n9 () XW";
|
||||
let layers = format!(
|
||||
"{}{}{}",
|
||||
stroked_box_symbol(),
|
||||
layer("Jig", true, &square(10, 10)),
|
||||
layer("Parts", true, &format!("{}\n{instance}", square(30, 110)))
|
||||
);
|
||||
let art = native(&layers).replacen("%AI5_NumLayers", "%AI3_TemplateBox: 50.5 50.5 50.5 50.5\n%AI5_NumLayers", 1);
|
||||
let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
|
||||
assert!(read, "{warnings:?}");
|
||||
assert_eq!(names(&d), ["Jig", "Parts"], "{warnings:?}");
|
||||
let parts = d.layers[1].children().unwrap();
|
||||
let above = parts[0].visual_bounds().unwrap();
|
||||
assert!(above.y1 <= 1e-6, "the square above the artboard is kept, outside it: {above:?}");
|
||||
let NodeKind::SymbolInstance { symbol, .. } = &parts[1].kind else { panic!("an instance: {:?} {warnings:?}", parts[1].kind) };
|
||||
assert_eq!(symbol, "Box");
|
||||
let b = parts[1].visual_bounds_with(&d.symbol_boxes()).unwrap();
|
||||
let near = [b.x0 - 149.0, b.y0 - 49.5, b.x1 - 151.0, b.y1 - 50.5].iter().all(|v| v.abs() < 0.3);
|
||||
assert!(near, "the instance right of the artboard: {b:?}");
|
||||
}
|
||||
|
||||
/// A `.ai` whose PDF part was read in points from a page of 10 points a unit (its `UserUnit`), and
|
||||
/// whose editing data has the page in those units: its layers come in at the page's size, ten
|
||||
/// times as large as the editing data has them, with stroke weights and dashes ten times as large.
|
||||
@@ -4763,6 +4791,9 @@ fn an_eps_page_of_its_art_stays_its_page_through_the_layers() {
|
||||
assert_eq!(boards.len(), 1);
|
||||
assert!((boards[0].width() - 4.0).abs() < 1e-6 && (boards[0].height() - 4.0).abs() < 1e-6, "{boards:?}");
|
||||
assert!(boards[0].inflate(1e-6, 1e-6).contains_rect(art), "{boards:?} round {art:?}");
|
||||
// Its private data is an EPS's editing copy, whatever the file is named.
|
||||
let named_ai = crate::import::import_ai(&file, true, false, crate::NameEncoding::default()).unwrap();
|
||||
assert_eq!(named_ai.document.artboards.iter().map(|a| a.rect).collect::<Vec<_>>(), boards, "{:?}", named_ai.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -49,7 +49,7 @@ mod tiff;
|
||||
pub use ai_editing::{AiEditingOptions, AiEditingOutput, CREATOR_VERSION, export_ai_editing, private_data as ai_private_data};
|
||||
pub use import::{
|
||||
AiLayers, Imported, NameEncoding, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard,
|
||||
family_style, import, import_names, import_trusting, import_with, is_loss, layered_ai, layered_ai_from, page,
|
||||
family_style, import, import_ai, import_names, import_trusting, import_with, is_loss, layered_ai, layered_ai_from, page,
|
||||
};
|
||||
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai, writes_image_mask};
|
||||
pub use print::{PrintJob, PrintPage, print};
|
||||
|
||||
@@ -1366,7 +1366,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"file.exportAs",
|
||||
"Export As…",
|
||||
"",
|
||||
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it",
|
||||
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, or while the file would name a linked image's file outside them, as document.export refuses it",
|
||||
),
|
||||
(
|
||||
"ui.fileInfoDialog",
|
||||
|
||||
@@ -556,10 +556,12 @@ impl Styles {
|
||||
}
|
||||
|
||||
/// The properties rules with a `:not()` set on `n`, each with its value after the cascade.
|
||||
/// Only the rules [`Index::candidates`] gives for `n` are tested, as in [`Styles::matched_bytes`].
|
||||
pub(super) fn set_by_not_rules(&self, n: XNode) -> Vec<(String, String)> {
|
||||
let declared = self.declared(n);
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for r in self.rules.iter().filter(|r| r.selector.has_not() && r.selector.matches(n)) {
|
||||
let rules = self.index.candidates(n).into_iter().filter_map(|i| self.rules.get(i));
|
||||
for r in rules.filter(|r| r.selector.has_not() && r.selector.matches(n)) {
|
||||
for (k, ..) in r.decls.iter() {
|
||||
if !out.iter().any(|(o, _)| o == k)
|
||||
&& let Some(v) = declared.get(k)
|
||||
|
||||
@@ -334,6 +334,26 @@ fn not_selectors() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The `:not()` rules filed under classes an element lacks are not tested against it, so
|
||||
/// [`Styles::set_by_not_rules`] tests no more rules than [`Styles::matched_bytes`] counts.
|
||||
#[test]
|
||||
fn not_rules_test_only_the_indexed_candidates() {
|
||||
const N: usize = 2_000;
|
||||
let rules: String = (0..N).map(|i| format!(".c{i}:not(.skip) {{ fill: #{i:06x} }}\n")).collect();
|
||||
let rects: String = (0..N).map(|i| format!(r#"<rect id="r{i}" class="c{i}"/>"#)).collect();
|
||||
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><style>{rules}</style>{rects}<rect id="skip" class="c0 skip"/></svg>"#);
|
||||
with_doc(&svg, |doc, st| {
|
||||
let before = selector_tests();
|
||||
for i in 0..N {
|
||||
assert_eq!(st.set_by_not_rules(by_id(doc, &format!("r{i}"))), [("fill".to_string(), format!("#{i:06x}"))]);
|
||||
}
|
||||
assert!(st.set_by_not_rules(by_id(doc, "skip")).is_empty());
|
||||
// Testing every `:not()` rule against every element would test N × N selectors.
|
||||
let tests = selector_tests() - before;
|
||||
assert!(tests <= 3 * N as u64, "finding the :not() rules of {N} elements tested {tests} selectors");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decl_comments_are_stripped() {
|
||||
let d = |k: &str, v: &str| (k.to_string(), v.to_string(), false);
|
||||
|
||||
@@ -758,7 +758,15 @@ struct Ctx {
|
||||
/// The path each `<textPath>` read so far follows ([`Self::text_path`]); `None` where it has
|
||||
/// no usable one. The styles of a `<use>` instance share it, so each path is found and parsed
|
||||
/// once however many `<use>` elements show its text.
|
||||
paths: Rc<RefCell<HashMap<roxmltree::NodeId, Option<Rc<BezPath>>>>>,
|
||||
paths: Rc<RefCell<HashMap<roxmltree::NodeId, Option<Rc<FollowedPath>>>>>,
|
||||
}
|
||||
|
||||
/// The path a `<textPath>` follows ([`Ctx::text_path`]).
|
||||
struct FollowedPath {
|
||||
path: BezPath,
|
||||
/// The `pathLength` of the element the `<textPath>` points at, 0 or greater; `None` when the
|
||||
/// `<textPath>` has its own `path` or the element has no such value.
|
||||
author_length: Option<f64>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1172,7 +1180,7 @@ impl Ctx {
|
||||
|
||||
/// The path a `<textPath>` follows, in the text's user space. It is read from the file once
|
||||
/// ([`Self::paths`]).
|
||||
fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<Rc<BezPath>> {
|
||||
fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<Rc<FollowedPath>> {
|
||||
if let Some(known) = self.paths.borrow().get(&tp.id()) {
|
||||
return known.clone();
|
||||
}
|
||||
@@ -1184,13 +1192,14 @@ impl Ctx {
|
||||
/// How many elements the path of `<textPath>` `tp` has, when a text built so far follows it
|
||||
/// ([`Self::text_path`]); 0 for any other node.
|
||||
fn path_elements(&self, tp: XNode) -> usize {
|
||||
self.paths.borrow().get(&tp.id()).and_then(|p| p.as_deref()).map_or(0, |p| p.elements().len())
|
||||
self.paths.borrow().get(&tp.id()).and_then(|p| p.as_deref()).map_or(0, |p| p.path.elements().len())
|
||||
}
|
||||
|
||||
/// Find and parse the path `<textPath>` `tp` follows.
|
||||
fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<BezPath> {
|
||||
fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<FollowedPath> {
|
||||
#[cfg(test)]
|
||||
TEXT_PATH_READS.set(TEXT_PATH_READS.get() + 1);
|
||||
let mut author_length = None;
|
||||
let mut bp = match tp.attribute("path") {
|
||||
Some(d) => BezPath::from_svg(d).ok()?,
|
||||
None => {
|
||||
@@ -1202,6 +1211,8 @@ impl Ctx {
|
||||
};
|
||||
let mut bp = BezPath::from_svg(&d).ok()?;
|
||||
bp.apply_affine(parse_transform(el.attribute("transform")));
|
||||
author_length =
|
||||
el.attribute("pathLength").and_then(|v| v.trim().parse::<f64>().ok()).filter(|l| l.is_finite() && *l >= 0.0).map(f64::abs);
|
||||
bp
|
||||
}
|
||||
};
|
||||
@@ -1209,7 +1220,7 @@ impl Ctx {
|
||||
bp = bp.reverse_subpaths();
|
||||
}
|
||||
let usable = bp.segments().next().is_some();
|
||||
usable.then_some(bp)
|
||||
usable.then_some(FollowedPath { path: bp, author_length })
|
||||
}
|
||||
|
||||
/// The equivalent path of a basic shape (SVG 2 › Basic Shapes), as path data: what a
|
||||
@@ -1458,7 +1469,8 @@ impl Ctx {
|
||||
let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default());
|
||||
let mut breaks: Vec<Break> = vec![];
|
||||
let mut para_styles: Vec<ParaStyle> = vec![];
|
||||
if let Some((tp, bp)) = path {
|
||||
if let Some((tp, followed)) = path {
|
||||
let bp = &followed.path;
|
||||
// Along the path: dx is extra advance, dy shifts off the path.
|
||||
let mut off = 0.0;
|
||||
for i in 0..n {
|
||||
@@ -1470,13 +1482,34 @@ impl Ctx {
|
||||
}
|
||||
let len: f64 = bp.segments().map(|s| s.arclen(1e-4)).sum();
|
||||
let em = self.font_size(tp);
|
||||
let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0];
|
||||
// `startOffset` as a share of the path's length. A percentage is of the path's computed
|
||||
// length. Any other length is in the units of the referenced element's `pathLength`
|
||||
// when it has one, and a `pathLength` of 0 puts any offset other than 0 infinitely far
|
||||
// along (SVG 2 › Paths › The pathLength attribute). An offset infinitely far along
|
||||
// starts as far past the path's end, or before its start, as type may go.
|
||||
let share = tp
|
||||
.attribute("startOffset")
|
||||
.and_then(|v| {
|
||||
let o = self.length(v, em, len)?;
|
||||
let share = match followed.author_length {
|
||||
Some(p) if !v.trim().ends_with('%') => {
|
||||
if o == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
o / p
|
||||
}
|
||||
}
|
||||
_ => o / len,
|
||||
};
|
||||
Some(if share.is_infinite() { vectorcraft_doc::PATH_LEAD_MAX.copysign(share) } else { share })
|
||||
})
|
||||
.unwrap_or(0.0);
|
||||
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
|
||||
// On a path of one closed subpath, text that starts before the path's start or past its
|
||||
// end wraps around the path. On an open path text may start before the path's start,
|
||||
// and its characters before the start are not drawn; text that starts past the end
|
||||
// starts at the end. Other paths start it at the path's start or end.
|
||||
let path = PathData::from_bezpath(&bp);
|
||||
let path = PathData::from_bezpath(bp);
|
||||
let wraps = path.subpaths.len() == 1 && path.is_closed();
|
||||
let open = !path.is_closed();
|
||||
let wrap = |s: f64| {
|
||||
@@ -1484,7 +1517,7 @@ impl Ctx {
|
||||
let s = s.rem_euclid(1.0);
|
||||
if s < 1.0 { s } else { 0.0 }
|
||||
};
|
||||
let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| {
|
||||
let start = Some(share + (dx[0] - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| {
|
||||
if wraps {
|
||||
wrap(s)
|
||||
} else if open {
|
||||
|
||||
@@ -21,4 +21,9 @@ fn uses_of_a_text_on_a_path_read_the_path_once() {
|
||||
// A path with no usable data is not read again either.
|
||||
let (texts, reads, _) = prepared(&format!(r##"<defs><text id="t"><textPath href="#none">Path</textPath></text></defs>{uses}"##));
|
||||
assert_eq!((texts, reads), (51, 1));
|
||||
// The path's pathLength is read with it.
|
||||
let (texts, reads, _) = prepared(&format!(
|
||||
r##"<defs><path id="p" d="M0 20L150 20" pathLength="10"/><text id="t"><textPath href="#p" startOffset="5">Path</textPath></text></defs>{uses}"##
|
||||
));
|
||||
assert_eq!((texts, reads), (51, 1));
|
||||
}
|
||||
|
||||
@@ -299,6 +299,38 @@ fn text_path_becomes_type_on_a_path() {
|
||||
assert_eq!(t[2].para.justify, Justify::Left);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_path_start_offset_is_scaled_by_the_path_length() {
|
||||
// A path 400 long whose pathLength is 100: a startOffset of 50 lies 200 along it. A
|
||||
// percentage stays a share of the computed length. Huge values of both still give their
|
||||
// ratio. With a pathLength of 0, an offset of 0 stays at the start and any other offset lies
|
||||
// past the end, as does an offset too large to scale on a short path.
|
||||
let d = open(
|
||||
r##"<path id="p" d="M0 100 H400" pathLength="100" fill="none" stroke="black"/>
|
||||
<path id="z" d="M0 200 H400" pathLength="0" fill="none" stroke="black"/>
|
||||
<path id="h" d="M0 300 H400" pathLength="1e308" fill="none" stroke="black"/>
|
||||
<path id="s" d="M0 400 H0.1" pathLength="1e-10" fill="none" stroke="black"/>
|
||||
<text font-size="10"><textPath href="#p" startOffset="50">Half</textPath></text>
|
||||
<text font-size="10"><textPath href="#p" startOffset="25%">Quarter</textPath></text>
|
||||
<text font-size="10"><textPath href="#h" startOffset="5e307">Huge</textPath></text>
|
||||
<text font-size="10"><textPath href="#z" startOffset="0">Zero</textPath></text>
|
||||
<text font-size="10"><textPath href="#z" startOffset="50">Fifty</textPath></text>
|
||||
<text font-size="10"><textPath href="#s" startOffset="1e308">Past</textPath></text>"##,
|
||||
);
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 6);
|
||||
let start_of = |t: &TextObject| match t.kind {
|
||||
TextKind::OnPath { start, .. } => start,
|
||||
_ => panic!("{:?}", t.kind),
|
||||
};
|
||||
assert!(close(start_of(t[0]), 0.5, 1e-9), "{}", start_of(t[0]));
|
||||
assert!(close(start_of(t[1]), 0.25, 1e-9), "{}", start_of(t[1]));
|
||||
assert!(close(start_of(t[2]), 0.5, 1e-9), "{}", start_of(t[2]));
|
||||
assert_eq!(start_of(t[3]), 0.0);
|
||||
assert_eq!(start_of(t[4]), 1.0);
|
||||
assert_eq!(start_of(t[5]), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_a_closed_path_wraps_past_the_path_start() {
|
||||
// A closed path 400 long: text anchored before its start, or offset past its end, wraps
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::cache::RegionCache;
|
||||
use super::lookup::{
|
||||
connected_edges, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_at_where, lp_stroke_paint, node_outline, visible_edge_near,
|
||||
visible_face_at,
|
||||
connected_edges, edges, faces, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_at_where, lp_stroke_paint, node_outline,
|
||||
visible_edge_near, visible_face_at,
|
||||
};
|
||||
use super::map::{HIGHLIGHT_RED, sorted_roots};
|
||||
use super::{
|
||||
@@ -711,8 +711,14 @@ pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0);
|
||||
/// with Shift held at the press, each of them leaves the selection if it is selected and joins it
|
||||
/// otherwise. A double-click on a face selects it and the faces of its group that are not parted
|
||||
/// from it by a stroked edge; on an edge, it selects the edge and the edges joined to it end to end
|
||||
/// that have its stroke paint ([`LivePaintSelectionTool::reach_at`]). With Shift held, each of them
|
||||
/// leaves the selection if it is selected and joins it otherwise.
|
||||
/// that have its stroke paint ([`LivePaintSelectionTool::reach_at`]). A triple-click on a face
|
||||
/// selects every face of its group with that face's fill; on an edge, every edge of its group with
|
||||
/// that edge's stroke paint ([`LivePaintSelectionTool::same_paint_at`]). With Shift held, each part a
|
||||
/// double-click reaches leaves the selection if it is selected and joins it otherwise, and after a
|
||||
/// Shift-triple-click each part with the clicked part's paint is toggled from the selection there
|
||||
/// was before the three clicks. Clicks of the three made without Shift select as they do alone, so
|
||||
/// when Shift is first held at a later click, the parts are toggled from the selection there was
|
||||
/// before that click.
|
||||
/// A click and a marquee change the selection on release, so Escape between the press
|
||||
/// and the release (which removes the marquee) leaves the selection as it was. The tool does not
|
||||
/// highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer
|
||||
@@ -733,6 +739,33 @@ pub struct LivePaintSelectionTool {
|
||||
width: f64,
|
||||
/// The drag in progress, from the press to the release.
|
||||
drag: Option<LpDrag>,
|
||||
/// The part each of the last two clicks toggled with Shift held, the latest last (None for a
|
||||
/// click that toggled nothing). A click or marquee without Shift clears both.
|
||||
recent: [Option<NodeId>; 2],
|
||||
/// The parts the last double-click, the two clicks before it and the Shift-clicks after it
|
||||
/// toggled, each listed once when toggled an odd number of times. A Shift-triple-click toggles
|
||||
/// them back as it toggles the parts with the clicked part's paint, so the selection ends as it
|
||||
/// was before the clicks with those parts toggled.
|
||||
toggled: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// `a` (which lists each id once) with each id of `b` toggled in turn: an id `b` lists an odd number
|
||||
/// of times is removed when `a` holds it and added at the end otherwise, in `b`'s order.
|
||||
fn toggle_ids(a: &mut Vec<NodeId>, b: &[NodeId]) {
|
||||
use std::collections::HashSet;
|
||||
let mut odd = HashSet::new();
|
||||
for id in b {
|
||||
if !odd.insert(*id) {
|
||||
odd.remove(id);
|
||||
}
|
||||
}
|
||||
let held: HashSet<NodeId> = a.iter().copied().collect();
|
||||
a.retain(|x| !odd.contains(x));
|
||||
for id in b {
|
||||
if !held.contains(id) && odd.remove(id) {
|
||||
a.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A press and drag of the Live Paint Selection tool.
|
||||
@@ -770,6 +803,8 @@ impl Default for LivePaintSelectionTool {
|
||||
color: HIGHLIGHT_RED,
|
||||
width: LP_SELECT_WIDTH,
|
||||
drag: None,
|
||||
recent: [None; 2],
|
||||
toggled: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -812,6 +847,25 @@ impl LivePaintSelectionTool {
|
||||
let Some(group) = cx.doc.node(gid) else { return vec![] };
|
||||
if edge { connected_edges(group, part.id, &lp_stroke_paint(part)) } else { flood_faces(group, part.id) }
|
||||
}
|
||||
|
||||
/// What a triple-click at `p` selects, Alt held or not: on the face a click there picks
|
||||
/// ([`LivePaintSelectionTool::picks_edge`]), that face and every other face of its group that is
|
||||
/// not hidden and has its fill; on the edge a click picks, that edge and every other edge of its
|
||||
/// group that is not hidden and draws with the stroke paint it draws with ([`lp_stroke_paint`]).
|
||||
/// The part under the pointer comes first. Empty where a click selects no face or edge.
|
||||
fn same_paint_at(&self, cx: &ToolContext, p: Point, alt: bool) -> Vec<NodeId> {
|
||||
let Some(edge) = self.picks_edge(alt) else { return vec![] };
|
||||
let Some((gid, Some(part))) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, &|id| lp_selectable(cx, id)) else { return vec![] };
|
||||
let Some(group) = cx.doc.node(gid) else { return vec![] };
|
||||
let others: Vec<NodeId> = if edge {
|
||||
let like = lp_stroke_paint(part);
|
||||
edges(group).into_iter().filter(|e| e.visible && e.id != part.id && lp_stroke_paint(e) == like).map(|e| e.id).collect()
|
||||
} else {
|
||||
let like = part.appearance.fill_paint();
|
||||
faces(group).into_iter().filter(|f| f.visible && f.id != part.id && f.appearance.fill_paint() == like).map(|f| f.id).collect()
|
||||
};
|
||||
std::iter::once(part.id).chain(others).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and
|
||||
@@ -918,6 +972,17 @@ impl Tool for LivePaintSelectionTool {
|
||||
let d = LpDrag { cur: p, ..d };
|
||||
let Some(r) = d.marquee(cx) else {
|
||||
// A click selects what the press picked.
|
||||
// A click without Shift replaces the selection, so what earlier clicks toggled
|
||||
// no longer counts.
|
||||
if d.shift {
|
||||
self.recent = [self.recent[1], d.hit];
|
||||
if let Some(id) = d.hit {
|
||||
toggle_ids(&mut self.toggled, &[id]);
|
||||
}
|
||||
} else {
|
||||
self.recent = [None; 2];
|
||||
self.toggled.clear();
|
||||
}
|
||||
return match (d.hit, d.shift) {
|
||||
(Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
|
||||
(Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))],
|
||||
@@ -925,6 +990,8 @@ impl Tool for LivePaintSelectionTool {
|
||||
(None, false) => vec![Action::Exec("select.none".into(), json!({}))],
|
||||
};
|
||||
};
|
||||
self.recent = [None; 2];
|
||||
self.toggled.clear();
|
||||
let ids = lp_parts_in(cx, r, self.fills, self.strokes);
|
||||
match (ids.is_empty(), d.shift) {
|
||||
(true, true) => vec![],
|
||||
@@ -937,15 +1004,36 @@ impl Tool for LivePaintSelectionTool {
|
||||
// The two clicks before it already selected the part under the pointer (or, with
|
||||
// Shift, toggled it twice); the double-click selects the part with what it reaches.
|
||||
let ids = self.reach_at(cx, p, ev.mods.alt);
|
||||
self.toggled.clear();
|
||||
if ev.mods.shift {
|
||||
let clicks: Vec<NodeId> = self.recent.iter().flatten().copied().collect();
|
||||
toggle_ids(&mut self.toggled, &clicks);
|
||||
toggle_ids(&mut self.toggled, &ids);
|
||||
}
|
||||
match (ids.is_empty(), ev.mods.shift) {
|
||||
(true, _) => vec![],
|
||||
(false, true) => vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&ids) }))],
|
||||
(false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))],
|
||||
}
|
||||
}
|
||||
// The tool takes no action on a triple-click; its third press and release select as a
|
||||
// click does.
|
||||
PointerKind::TripleClick => vec![],
|
||||
PointerKind::TripleClick => {
|
||||
// The clicks before it already selected the part under the pointer; the
|
||||
// triple-click selects the part with every part of its group that has its paint.
|
||||
// With Shift held, the double-click and the third click toggled parts too, and the
|
||||
// triple-click toggles those back.
|
||||
let ids = self.same_paint_at(cx, p, ev.mods.alt);
|
||||
let toggled = std::mem::take(&mut self.toggled);
|
||||
self.recent = [None; 2];
|
||||
if ids.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
if !ev.mods.shift {
|
||||
return vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))];
|
||||
}
|
||||
let mut flip = ids;
|
||||
toggle_ids(&mut flip, &toggled);
|
||||
if flip.is_empty() { vec![] } else { vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&flip) }))] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -963,6 +1051,8 @@ impl Tool for LivePaintSelectionTool {
|
||||
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
self.drag = None;
|
||||
self.recent = [None; 2];
|
||||
self.toggled.clear();
|
||||
vec![]
|
||||
}
|
||||
|
||||
|
||||
@@ -2573,20 +2573,167 @@ fn live_paint_selection_double_click_selects_connected_faces_or_edges() {
|
||||
assert!(dbl(&mut t, 25.0, 1.0, alt).is_empty());
|
||||
}
|
||||
|
||||
/// A triple-click on a face selects every face of its group with that face's fill, joined to it or
|
||||
/// not; on an edge (Alt), every edge of its group that draws with that edge's stroke paint. Shift
|
||||
/// toggles them. Hidden parts, other groups and parts with another paint are left out.
|
||||
#[test]
|
||||
fn live_paint_selection_triple_click_adds_nothing_to_its_clicks() {
|
||||
fn live_paint_selection_triple_click_selects_every_part_of_the_same_paint() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let (_, [a, ..], _) = live_strip(&mut d);
|
||||
let (_, [a, b, c], [ab, bc, top_a, top_b, top_c, bottom]) = live_strip(&mut d);
|
||||
let (other, [_, other_top, ..]) = live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0));
|
||||
let other_face = faces(d.node(other).unwrap())[0].id;
|
||||
let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
|
||||
let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0));
|
||||
// A and C are red and B is blue; the other group's face is red and its top edge black, too.
|
||||
for (id, fill) in [(a, &red), (b, &blue), (c, &red), (other_face, &red)] {
|
||||
d.node_mut(id).unwrap().appearance.set_fill(fill.clone());
|
||||
}
|
||||
d.node_mut(other_top).unwrap().appearance.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK));
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))];
|
||||
let toggle = |ids: &[NodeId]| vec![Action::Exec("select.toggle".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))];
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let tri = |d: &Document, t: &mut LivePaintSelectionTool, x: f64, y: f64, m: Mods| {
|
||||
let cx = cx(d, &s, &p);
|
||||
t.pointer(&cx, &ev(PointerKind::TripleClick, x, y).with_mods(m))
|
||||
};
|
||||
let mut t = LivePaintSelectionTool::default();
|
||||
// A face: every face of its group with its fill, even across the stroked edge BC; the clicked face first.
|
||||
assert_eq!(tri(&d, &mut t, 125.0, 25.0, Mods::default()), set(&[c, a]));
|
||||
assert_eq!(tri(&d, &mut t, 75.0, 25.0, Mods::default()), set(&[b]));
|
||||
// An edge: every edge of its group that draws with its stroke paint, joined to it or not.
|
||||
assert_eq!(tri(&d, &mut t, 25.0, 1.0, alt), set(&[top_a, top_b, bottom]));
|
||||
assert_eq!(tri(&d, &mut t, 125.0, 1.0, alt), set(&[top_c, bc]));
|
||||
assert_eq!(tri(&d, &mut t, 50.0, 25.0, alt), set(&[ab]));
|
||||
// With Shift held, the parts leave the selection or join it.
|
||||
assert_eq!(tri(&d, &mut t, 25.0, 25.0, shift), toggle(&[a, c]));
|
||||
assert_eq!(tri(&d, &mut t, 125.0, 1.0, Mods { alt: true, shift: true, ..Default::default() }), toggle(&[top_c, bc]));
|
||||
// Away from the group, a triple-click adds nothing to what its clicks did.
|
||||
assert!(tri(&d, &mut t, 400.0, 400.0, Mods::default()).is_empty());
|
||||
// A hidden face of the same fill is left out.
|
||||
d.node_mut(c).unwrap().visible = false;
|
||||
assert_eq!(tri(&d, &mut t, 25.0, 25.0, Mods::default()), set(&[a]));
|
||||
// On the other group, a triple-click selects that group's parts alone.
|
||||
assert_eq!(tri(&d, &mut t, 250.0, 50.0, Mods::default()), set(&[other_face]));
|
||||
assert_eq!(tri(&d, &mut t, 250.0, 1.0, alt), set(&[other_top]));
|
||||
// Select Fills off: the triple-click picks the edge nearest the pointer; both off: nothing.
|
||||
t.set_option("selectFills", &json!(false));
|
||||
assert_eq!(tri(&d, &mut t, 25.0, 1.0, Mods::default()), set(&[top_a, top_b, bottom]));
|
||||
t.set_option("selectStrokes", &json!(false));
|
||||
assert!(tri(&d, &mut t, 25.0, 25.0, Mods::default()).is_empty());
|
||||
assert!(tri(&d, &mut t, 25.0, 1.0, alt).is_empty());
|
||||
}
|
||||
|
||||
/// The whole event series of a triple-click (three presses and releases, the double-click after
|
||||
/// the second release and the triple-click after the third), applied to a selection: without
|
||||
/// Shift the selection ends as the parts with the clicked part's paint, and with Shift as the
|
||||
/// selection from before the clicks with those parts toggled, whatever the double-click reached.
|
||||
#[test]
|
||||
fn live_paint_selection_triple_click_series_ends_with_the_same_paint_parts() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let (_, [a, b, c], [_, bc, top_a, top_b, top_c, bottom]) = live_strip(&mut d);
|
||||
let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
|
||||
let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0));
|
||||
// A and C are red and B is blue; a double-click on A also reaches B across the unstroked edge AB.
|
||||
for (id, fill) in [(a, &red), (b, &blue), (c, &red)] {
|
||||
d.node_mut(id).unwrap().appearance.set_fill(fill.clone());
|
||||
}
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let apply = |sel: &mut Vec<u64>, acts: Vec<Action>| {
|
||||
for act in acts {
|
||||
let Action::Exec(cmd, args) = act else { panic!("{act:?}") };
|
||||
let ids: Vec<u64> = match (args.get("ids"), args.get("id")) {
|
||||
(Some(v), _) => v.as_array().unwrap().iter().map(|i| i.as_u64().unwrap()).collect(),
|
||||
(None, Some(i)) => vec![i.as_u64().unwrap()],
|
||||
(None, None) => vec![],
|
||||
};
|
||||
match cmd.as_str() {
|
||||
"select.set" => *sel = ids,
|
||||
"select.none" => sel.clear(),
|
||||
"select.toggle" => {
|
||||
for i in ids {
|
||||
match sel.iter().position(|x| *x == i) {
|
||||
Some(k) => {
|
||||
sel.remove(k);
|
||||
}
|
||||
None => sel.push(i),
|
||||
}
|
||||
}
|
||||
}
|
||||
other => panic!("{other}"),
|
||||
}
|
||||
}
|
||||
};
|
||||
// The first click at `first`, every later event at (`x`, `y`).
|
||||
let series = |t: &mut LivePaintSelectionTool, sel: &mut Vec<u64>, first: (f64, f64), x: f64, y: f64, m: Mods| {
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
apply(sel, t.pointer(&cx, &ev(kind, first.0, first.1).with_mods(m)));
|
||||
}
|
||||
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick, PointerKind::Down, PointerKind::Up, PointerKind::TripleClick] {
|
||||
apply(sel, t.pointer(&cx, &ev(kind, x, y).with_mods(m)));
|
||||
}
|
||||
};
|
||||
let sorted = |ids: &[NodeId]| {
|
||||
let mut v: Vec<u64> = ids.iter().map(|i| i.0).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
};
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let mut t = LivePaintSelectionTool::default();
|
||||
// The third click's press and release select the face under the pointer, as a click does.
|
||||
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 25.0, 25.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [a.0] }))]);
|
||||
for m in [Mods::default(), Mods { alt: true, ..Default::default() }, Mods { shift: true, ..Default::default() }] {
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::TripleClick, 25.0, 25.0).with_mods(m)).is_empty());
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::TripleClick, 25.0, 1.0).with_mods(m)).is_empty());
|
||||
for (start, first, x, y, m, want) in [
|
||||
(vec![], (25.0, 25.0), 25.0, 25.0, Mods::default(), sorted(&[a, c])),
|
||||
(vec![top_c.0], (25.0, 25.0), 25.0, 25.0, Mods::default(), sorted(&[a, c])),
|
||||
(vec![], (25.0, 25.0), 25.0, 25.0, shift, sorted(&[a, c])),
|
||||
(vec![top_c.0, c.0], (25.0, 25.0), 25.0, 25.0, shift, sorted(&[a, top_c])),
|
||||
(vec![], (25.0, 1.0), 25.0, 1.0, Mods { alt: true, shift: true, ..Default::default() }, sorted(&[top_a, top_b, bottom])),
|
||||
(vec![bc.0], (125.0, 1.0), 125.0, 1.0, alt, sorted(&[top_c, bc])),
|
||||
// The first click on A and the rest on B, beside it: B alone has B's fill.
|
||||
(vec![], (49.0, 25.0), 51.0, 25.0, shift, sorted(&[b])),
|
||||
(vec![c.0], (49.0, 25.0), 51.0, 25.0, shift, sorted(&[b, c])),
|
||||
] {
|
||||
let mut sel = start.clone();
|
||||
series(&mut t, &mut sel, first, x, y, m);
|
||||
sel.sort_unstable();
|
||||
assert_eq!(sel, want, "from {start:?} at {first:?} then ({x}, {y}) with {m:?}");
|
||||
}
|
||||
// Shift first held at the third click: the two clicks and the double-click select A and B, and
|
||||
// the parts with A's fill are toggled from that selection.
|
||||
let mut sel = vec![top_c.0];
|
||||
for (kind, m) in [
|
||||
(PointerKind::Down, Mods::default()),
|
||||
(PointerKind::Up, Mods::default()),
|
||||
(PointerKind::Down, Mods::default()),
|
||||
(PointerKind::Up, Mods::default()),
|
||||
(PointerKind::DoubleClick, Mods::default()),
|
||||
(PointerKind::Down, shift),
|
||||
(PointerKind::Up, shift),
|
||||
(PointerKind::TripleClick, shift),
|
||||
] {
|
||||
apply(&mut sel, t.pointer(&cx, &ev(kind, 25.0, 25.0).with_mods(m)));
|
||||
}
|
||||
sel.sort_unstable();
|
||||
assert_eq!(sel, sorted(&[b, c]));
|
||||
// A Shift-click on C, then a click on A without Shift, which selects A alone, and Shift held
|
||||
// from the double-click on: the parts with A's fill are toggled from that selection.
|
||||
let mut sel = vec![];
|
||||
for (kind, x, m) in [
|
||||
(PointerKind::Down, 125.0, shift),
|
||||
(PointerKind::Up, 125.0, shift),
|
||||
(PointerKind::Down, 25.0, Mods::default()),
|
||||
(PointerKind::Up, 25.0, Mods::default()),
|
||||
(PointerKind::DoubleClick, 25.0, shift),
|
||||
(PointerKind::Down, 25.0, shift),
|
||||
(PointerKind::Up, 25.0, shift),
|
||||
(PointerKind::TripleClick, 25.0, shift),
|
||||
] {
|
||||
apply(&mut sel, t.pointer(&cx, &ev(kind, x, 25.0).with_mods(m)));
|
||||
}
|
||||
assert_eq!(sel, vec![c.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -188,6 +188,8 @@ fn snapped(d: Vec2, unsnapped: Vec2, shift: bool, guides: &[Overlay]) -> bool {
|
||||
pub struct DirectSelectionTool {
|
||||
group: bool,
|
||||
state: State,
|
||||
/// How far the move's last preview carried the anchors or the objects ([`Tool::readout`]).
|
||||
moved: Option<Vec2>,
|
||||
/// The spine point last clicked (blend, anchor): its handles show and can be dragged.
|
||||
spine: Option<(NodeId, usize)>,
|
||||
/// Other points of that spine picked with it (Shift-click adds or takes one away); dragging
|
||||
@@ -251,6 +253,7 @@ impl DirectSelectionTool {
|
||||
Self {
|
||||
group,
|
||||
state: State::Idle,
|
||||
moved: None,
|
||||
spine: None,
|
||||
spine_more: vec![],
|
||||
mesh: MeshEdit::default(),
|
||||
@@ -733,6 +736,10 @@ impl Tool for DirectSelectionTool {
|
||||
fn busy(&self) -> bool {
|
||||
!matches!(self.state, State::Idle) || self.guide.busy()
|
||||
}
|
||||
fn readout(&self, _cx: &ToolContext) -> Option<crate::Readout> {
|
||||
let moving = matches!(self.state, State::MoveAnchors { began: true, .. } | State::MoveObject { began: true, .. });
|
||||
self.moved.filter(|_| moving).map(crate::Readout::drag)
|
||||
}
|
||||
fn options(&self) -> Value {
|
||||
self.mesh.options()
|
||||
}
|
||||
@@ -1486,6 +1493,7 @@ impl DirectSelectionTool {
|
||||
(d, self.guides) = (q - grab, guides);
|
||||
}
|
||||
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
|
||||
self.moved = Some(d);
|
||||
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
|
||||
out
|
||||
}
|
||||
@@ -1538,6 +1546,7 @@ impl DirectSelectionTool {
|
||||
(d, self.guides) = (q - held, guides);
|
||||
}
|
||||
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
|
||||
self.moved = Some(d);
|
||||
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
|
||||
return out;
|
||||
}
|
||||
@@ -1553,6 +1562,7 @@ impl DirectSelectionTool {
|
||||
(d, self.guides) = if ev.mods.shift { snap.snap_along(cx, hold, d, ev.mods.alt) } else { snap.snap(cx, hold, d, ev.mods.alt) };
|
||||
}
|
||||
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
|
||||
self.moved = Some(d);
|
||||
out.push(Action::Preview(
|
||||
"object.transform".into(),
|
||||
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}),
|
||||
|
||||
@@ -378,9 +378,11 @@ impl PerspectiveGrid {
|
||||
if (self.origin[1] - self.horizon).abs() < 1e-6 {
|
||||
return Err("ground level must differ from the horizon".into());
|
||||
}
|
||||
// A moved plane stays in front of the viewer.
|
||||
let behind = |p: Plane| self.axes(p).is_some_and(|[.., n]| 1.0 + self.offset(p) * self.axis_col(n)[2] <= 1e-9);
|
||||
if Plane::ALL.iter().any(|p| !self.offset(*p).is_finite() || self.offset(*p).abs() > MAX_DEPTH || behind(*p)) {
|
||||
// A moved plane stays in front of the viewer, both where Automatic Plane Positioning holds
|
||||
// it and in its own place, which it goes back to.
|
||||
let behind = |p: Plane, d: f64| self.axes(p).is_some_and(|[.., n]| 1.0 + d * self.axis_col(n)[2] <= 1e-9);
|
||||
let bad = |p: Plane, d: f64| !d.is_finite() || d.abs() > MAX_DEPTH || behind(p, d);
|
||||
if Plane::ALL.iter().any(|p| bad(*p, self.offset(*p)) || bad(*p, self.placed_offset(*p))) {
|
||||
return Err("a plane is moved too far (behind the viewer)".into());
|
||||
}
|
||||
self.validate_definition()
|
||||
|
||||
@@ -131,17 +131,32 @@ impl PerspectiveGrid {
|
||||
for plane in self.shown_planes() {
|
||||
let depth = self.offset(plane);
|
||||
let Some(h) = self.homography_at(plane, depth) else { continue };
|
||||
let Some(q) = h.inverse().and_then(|hi| hi.apply(p)) else { continue };
|
||||
let Some(hi) = h.inverse() else { continue };
|
||||
let Some(((su, nu), (sv, nv))) = self.line_steps(plane) else { continue };
|
||||
// The drawn lines nearest the pointer along each axis, and their neighbors: the
|
||||
// projection skews and shrinks the cells, so the intersection nearest on the page can be
|
||||
// one over from the nearest in the plane.
|
||||
let near = |v: f64, step: f64, last: usize| {
|
||||
let i = (v / step).round().clamp(0.0, last as f64) as i64;
|
||||
(i - 2..=i + 2).filter(move |k| (0..=last as i64).contains(k)).map(move |k| k as f64 * step)
|
||||
// On each drawn line, the intersections either side of the point of the line nearest
|
||||
// the pointer on the page. The projection keeps the order of points along a line, so
|
||||
// the nearest intersection on that line is one of those two, however skewed the cells.
|
||||
let mut candidates = Vec::with_capacity(2 * (nu + nv + 2));
|
||||
let mut along = |line: &dyn Fn(f64) -> Point, step: f64, last: usize, coord: fn(Point) -> f64| {
|
||||
let (Some(a), Some(b)) = (h.apply(line(0.0)), h.apply(line(last as f64 * step))) else { return };
|
||||
let ab = b - a;
|
||||
let len2 = ab.x * ab.x + ab.y * ab.y;
|
||||
let t = if len2 > 0.0 { (((p - a).x * ab.x + (p - a).y * ab.y) / len2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let Some(at) = hi.apply(a + ab * t).map(coord).filter(|v| v.is_finite()) else { return };
|
||||
let k = (at / step).floor().clamp(0.0, last as f64);
|
||||
for k in [k, (k + 1.0).min(last as f64)] {
|
||||
candidates.push(line(k * step));
|
||||
}
|
||||
};
|
||||
for (u, v) in near(q.x, su, nu).flat_map(|u| near(q.y, sv, nv).map(move |v| (u, v))) {
|
||||
let q = Point::new(u, v);
|
||||
for i in 0..=nu {
|
||||
let u = i as f64 * su;
|
||||
along(&|v| Point::new(u, v), sv, nv, |q| q.y);
|
||||
}
|
||||
for i in 0..=nv {
|
||||
let v = i as f64 * sv;
|
||||
along(&|u| Point::new(u, v), su, nu, |q| q.x);
|
||||
}
|
||||
for q in candidates {
|
||||
let Some(d) = h.apply(q).map(|at| at.distance(p)) else { continue };
|
||||
if d <= tol
|
||||
&& best.is_none_or(|b| d < b.0)
|
||||
@@ -268,6 +283,33 @@ mod tests {
|
||||
assert!(g.infer_at(&d, past, 3.0, PlaneTargets::ALL).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_plane_still_checks_its_own_place() {
|
||||
// Held at x = 0, the left wall's own place must stay in front of the viewer too: it goes
|
||||
// back there once positioning ends.
|
||||
let g = PerspectiveGrid { plane: Plane::Left, inferred: Some([0.0; 3]), ..grid() };
|
||||
assert!(g.merged(&json!({"leftOffset": -800})).is_err());
|
||||
assert!(PerspectiveGrid { inferred: None, ..g.clone() }.merged(&json!({"leftOffset": -800})).is_err());
|
||||
let moved = g.merged(&json!({"leftOffset": 40})).unwrap();
|
||||
assert_eq!((moved.placed_offset(Plane::Left), moved.offset(Plane::Left), moved.inferred), (40.0, 0.0, Some([0.0; 3])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_nearest_intersection_on_the_page_is_found_on_a_skewed_plane() {
|
||||
// The left vanishing point next to the origin skews the left wall's cells hard: the
|
||||
// intersection nearest on the page lies many cells from the pointer's inverse.
|
||||
let g = PerspectiveGrid { vp_left: 399.0, plane: Plane::Left, inferred: Some([0.0; 3]), ..grid() };
|
||||
let d = Document::new(800.0, 600.0);
|
||||
let gridlines = PlaneTargets { anchors: false, gridlines: true };
|
||||
let p = Point::new(400.0, 219.761194);
|
||||
let want = Point::new(10.0 * g.cell, 10.0 * g.cell);
|
||||
assert!((g.to_page(Plane::Left, want).unwrap() - p).hypot() < 0.5);
|
||||
let (_, at) = g.infer_at(&d, p, 6.0, gridlines).unwrap();
|
||||
for (got, want) in at.iter().zip([0.0, want.y, want.x]) {
|
||||
assert!((got - want).abs() < 1e-6, "{at:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_path_inside_art_in_perspective_has_no_anchor_to_take() {
|
||||
let g = PerspectiveGrid { plane: Plane::Right, ..grid() };
|
||||
|
||||
@@ -57,10 +57,12 @@ pub use catalog::{TOOL_GROUPS, ToolInfo, tool_info};
|
||||
mod context;
|
||||
mod cursor;
|
||||
mod input;
|
||||
mod readout;
|
||||
|
||||
pub use context::{GlyphSnap, MarqueeMode, PaintDefaults, PixelOp, PixelSnap, ScreenFrame, ToolContext};
|
||||
pub use cursor::Cursor;
|
||||
pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey};
|
||||
pub use readout::Readout;
|
||||
|
||||
/// Visual feedback drawn by the UI in screen space (coordinates here are document space).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -265,6 +267,12 @@ pub trait Tool: Send {
|
||||
fn wants_rehover(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// What the Info panel shows for the tool as it is now: the drag in progress (a move of the
|
||||
/// selection, a gradient vector, a Scale, Rotate, Reflect or Shear tool drag), or the
|
||||
/// Paintbrush's brush. None for a tool with nothing of its own to show.
|
||||
fn readout(&self, _cx: &ToolContext) -> Option<Readout> {
|
||||
None
|
||||
}
|
||||
/// Called when the user switches away (finish pending work).
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
vec![]
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! What the tool in use gives the Info panel to show ([`crate::Tool::readout`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
/// The Info panel's values for the tool in use. Lengths and points are in document units, and
|
||||
/// angles are in degrees as the matching command takes them: counter-clockwise for
|
||||
/// `object.rotate`'s `angle` and `object.reflect`'s `axis`, and `object.shear`'s `angle`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Readout {
|
||||
/// A drag that moves the selection or draws a gradient vector: how far it has gone in x and y.
|
||||
Drag { dx: f64, dy: f64 },
|
||||
/// A Scale tool drag: the horizontal and vertical scale (1 leaves the size as it is).
|
||||
Scale { sx: f64, sy: f64 },
|
||||
/// A Rotate tool drag: where the center of the selection's bounding box is, and the angle
|
||||
/// turned.
|
||||
Rotate { center: Point, angle: f64 },
|
||||
/// A Reflect tool drag: where the center of the selection's bounding box is, and the angle of
|
||||
/// the axis.
|
||||
Reflect { center: Point, angle: f64 },
|
||||
/// A Shear tool drag: where the center of the selection's bounding box is, the angle of the
|
||||
/// shear axis (0 horizontal, 90 vertical) and the shear angle.
|
||||
Shear { center: Point, axis: f64, angle: f64 },
|
||||
/// The Paintbrush: the brush it paints with, if any.
|
||||
Brush { name: Option<String> },
|
||||
}
|
||||
|
||||
impl Readout {
|
||||
/// The readout of a drag by `d`.
|
||||
pub fn drag(d: Vec2) -> Self {
|
||||
Readout::Drag { dx: d.x, dy: d.y }
|
||||
}
|
||||
|
||||
/// The length of a [`Readout::Drag`] and its angle, as the Measure tool gives them.
|
||||
pub fn distance_angle(&self) -> Option<(f64, f64)> {
|
||||
match *self {
|
||||
Readout::Drag { dx, dy } => {
|
||||
let (dist, angle, ..) = crate::xform::measure(Point::ORIGIN, Point::new(dx, dy));
|
||||
Some((dist, angle))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The readout as `info.readout` reports it.
|
||||
pub fn to_json(&self) -> Value {
|
||||
let at = |p: Point| json!([p.x, p.y]);
|
||||
match self {
|
||||
Readout::Drag { dx, dy } => {
|
||||
let (distance, angle) = self.distance_angle().unwrap_or_default();
|
||||
json!({ "kind": "drag", "dx": dx, "dy": dy, "distance": distance, "angle": angle })
|
||||
}
|
||||
Readout::Scale { sx, sy } => json!({ "kind": "scale", "width": sx * 100.0, "height": sy * 100.0 }),
|
||||
Readout::Rotate { center, angle } => json!({ "kind": "rotate", "center": at(*center), "angle": angle }),
|
||||
Readout::Reflect { center, angle } => json!({ "kind": "reflect", "center": at(*center), "angle": angle }),
|
||||
Readout::Shear { center, axis, angle } => json!({ "kind": "shear", "center": at(*center), "axis": axis, "angle": angle }),
|
||||
Readout::Brush { name } => json!({ "kind": "brush", "brush": name }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_drag_gives_its_length_and_its_angle_up_the_page() {
|
||||
let r = Readout::drag(Vec2::new(30.0, -40.0));
|
||||
let (d, a) = r.distance_angle().unwrap();
|
||||
assert!((d - 50.0).abs() < 1e-9 && (a - 53.13010235415598).abs() < 1e-9, "{d} {a}");
|
||||
assert_eq!(r.to_json()["dx"], 30.0);
|
||||
assert_eq!(Readout::Scale { sx: 1.5, sy: 0.5 }.to_json(), json!({"kind": "scale", "width": 150.0, "height": 50.0}));
|
||||
assert_eq!(Readout::Scale { sx: 1.0, sy: 1.0 }.distance_angle(), None);
|
||||
assert_eq!(Readout::Brush { name: None }.to_json(), json!({"kind": "brush", "brush": null}));
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,8 @@ pub struct SelectionTool {
|
||||
/// The params of the move's last preview: in Shaper's Construction mode the move ends with
|
||||
/// them and `releaseOutside` ([`construction`]).
|
||||
last_move: Option<Value>,
|
||||
/// How far the move's last preview carried the selection ([`Tool::readout`]).
|
||||
moved: Option<Vec2>,
|
||||
/// Held state used for the most recent bounding-box preview.
|
||||
last_backquote: bool,
|
||||
/// Pointer position used for the most recent bounding-box preview.
|
||||
@@ -898,6 +900,10 @@ impl Tool for SelectionTool {
|
||||
matches!(self.state, State::Moving { began: true, .. })
|
||||
}
|
||||
|
||||
fn readout(&self, _cx: &ToolContext) -> Option<crate::Readout> {
|
||||
self.moved.filter(|_| self.moving()).map(crate::Readout::drag)
|
||||
}
|
||||
|
||||
fn transforming(&self) -> bool {
|
||||
match &self.state {
|
||||
State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true,
|
||||
@@ -1753,6 +1759,7 @@ impl SelectionTool {
|
||||
params["patterns"] = json!(true);
|
||||
}
|
||||
self.last_move = Some(params.clone());
|
||||
self.moved = Some(d);
|
||||
out.push(Action::Preview("object.transform".into(), params));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -299,6 +299,9 @@ impl Tool for BrushPaintTool {
|
||||
fn busy(&self) -> bool {
|
||||
self.inner.busy()
|
||||
}
|
||||
fn readout(&self, cx: &ToolContext) -> Option<crate::Readout> {
|
||||
Some(crate::Readout::Brush { name: self.brush_name(cx) })
|
||||
}
|
||||
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||||
let acts = self.inner.deactivate(cx);
|
||||
self.map(cx, acts)
|
||||
|
||||
@@ -545,6 +545,12 @@ impl Tool for GradientTool {
|
||||
self.gesture.as_ref().is_some_and(|g| g.began) || self.free.as_ref().is_some_and(freeform::Gesture::began)
|
||||
}
|
||||
|
||||
/// The vector a drag on the art has drawn so far.
|
||||
fn readout(&self, _cx: &ToolContext) -> Option<crate::Readout> {
|
||||
let (start, end) = self.gesture.as_ref().filter(|g| g.began)?.vector?;
|
||||
Some(crate::Readout::drag(end - start))
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
// A freeform gradient: its points instead of the bar.
|
||||
match (ev.kind, &mut self.free) {
|
||||
|
||||
@@ -20,7 +20,7 @@ use super::target_overlays;
|
||||
use crate::bbox::rotate_for_drag;
|
||||
use crate::guides::{PointSnap, Targets, snap_pick};
|
||||
use crate::select::{matrix_json, selection_bounds, selection_box};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Readout, Tool, ToolContext, ToolKey};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TransformKind {
|
||||
@@ -98,6 +98,8 @@ pub struct TransformTool {
|
||||
origin: Option<(Point, Vec<NodeId>)>,
|
||||
drag: Option<Drag>,
|
||||
measure: Option<(Point, String)>,
|
||||
/// What the Info panel shows for the drag in progress.
|
||||
readout: Option<Readout>,
|
||||
/// Snap feedback while hovering ("anchor" / "center" label), and for the dragged pointer.
|
||||
hover: Vec<Overlay>,
|
||||
/// What the dragged pointer snaps to, gathered when the drag begins.
|
||||
@@ -126,7 +128,7 @@ pub fn reflect_matrix(o: Point, theta: f64) -> Affine {
|
||||
|
||||
impl TransformTool {
|
||||
pub fn new(kind: TransformKind) -> Self {
|
||||
Self { kind, origin: None, drag: None, measure: None, hover: vec![], snap: None }
|
||||
Self { kind, origin: None, drag: None, measure: None, readout: None, hover: vec![], snap: None }
|
||||
}
|
||||
|
||||
/// The effective reference point: the custom one if it belongs to the current selection,
|
||||
@@ -154,12 +156,44 @@ impl TransformTool {
|
||||
/// and Shift's 45° steps start from the box's axes. A Reflect axis drawn by the drag is the
|
||||
/// line the pointer draws on the page.
|
||||
pub fn matrix_in(&self, angle: f64, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) {
|
||||
let (m, a, b) = self.solve(angle, o, start, p, shift, bounds);
|
||||
(m, self.label(a, b))
|
||||
}
|
||||
|
||||
/// The canvas label for the two values [`Self::solve`] gives.
|
||||
fn label(&self, a: f64, b: f64) -> String {
|
||||
match self.kind {
|
||||
TransformKind::Rotate => format!("{:.1}°", -a),
|
||||
TransformKind::Reflect => format!("{a:.1}°"),
|
||||
TransformKind::Scale => format!("W: {:.1}%\nH: {:.1}%", a * 100.0, b * 100.0),
|
||||
TransformKind::Shear => format!("{:.1}°", if a == 0.0 { b } else { -b }),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the Info panel shows for the transform `m` of a selection with `bounds`, with the two
|
||||
/// values [`Self::solve`] gives.
|
||||
fn readout_of(&self, m: Affine, a: f64, b: f64, bounds: Rect) -> Readout {
|
||||
let center = m * bounds.center();
|
||||
match self.kind {
|
||||
TransformKind::Rotate => Readout::Rotate { center, angle: a },
|
||||
TransformKind::Reflect => Readout::Reflect { center, angle: a },
|
||||
TransformKind::Scale => Readout::Scale { sx: a, sy: b },
|
||||
TransformKind::Shear => Readout::Shear { center, axis: a, angle: b },
|
||||
}
|
||||
}
|
||||
|
||||
/// [`TransformTool::matrix_in`]'s transform, with the two values of its readout: the angle
|
||||
/// turned, in degrees counter-clockwise as `object.rotate` takes it (Rotate), the angle of the
|
||||
/// axis in degrees counter-clockwise (Reflect), the scale along the box's width and height
|
||||
/// (Scale), or the angle of the shear axis (0 horizontal, 90 vertical) and the shear angle as
|
||||
/// `object.shear` takes it (Shear).
|
||||
fn solve(&self, angle: f64, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, f64, f64) {
|
||||
let frame = OrientedBox { angle, rect: Rect::ZERO };
|
||||
let local = |v: Vec2| frame.to_doc().inverse() * v.to_point();
|
||||
match self.kind {
|
||||
TransformKind::Rotate => {
|
||||
let (a, deg) = rotate_for_drag(o, start, p, shift);
|
||||
(a, format!("{:.1}°", -deg))
|
||||
(a, deg, 0.0)
|
||||
}
|
||||
TransformKind::Reflect => {
|
||||
let mut v = p - o;
|
||||
@@ -169,7 +203,7 @@ impl TransformTool {
|
||||
v = (frame.to_doc() * vectorcraft_geom::constrain_angle(local(v).to_vec2(), 45.0).to_point()).to_vec2();
|
||||
}
|
||||
let theta = v.y.atan2(v.x);
|
||||
(reflect_matrix(o, theta), format!("{:.1}°", vectorcraft_geom::normalize_deg(-theta.to_degrees())))
|
||||
(reflect_matrix(o, theta), vectorcraft_geom::normalize_deg(-theta.to_degrees()), 0.0)
|
||||
}
|
||||
TransformKind::Scale => {
|
||||
let d0 = local(start - o).to_vec2();
|
||||
@@ -184,7 +218,7 @@ impl TransformTool {
|
||||
}
|
||||
let clamp = |s: f64| if s.abs() < 1e-4 { 1e-4f64.copysign(s) } else { s };
|
||||
let (sx, sy) = (clamp(sx), clamp(sy));
|
||||
(about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0))
|
||||
(about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), sx, sy)
|
||||
}
|
||||
TransformKind::Shear => {
|
||||
// The shear axis follows the dominant drag direction. Shift keeps the drag on that
|
||||
@@ -223,7 +257,7 @@ impl TransformTool {
|
||||
};
|
||||
}
|
||||
}
|
||||
(m, format!("{:.1}°", (-k.atan()).to_degrees()))
|
||||
if horizontal { (m, 0.0, (-k.atan()).to_degrees()) } else { (m, 90.0, k.atan().to_degrees()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,6 +265,7 @@ impl TransformTool {
|
||||
/// Ends the drag, its snapping and readouts with it.
|
||||
fn end_drag(&mut self) -> Option<Drag> {
|
||||
self.measure = None;
|
||||
self.readout = None;
|
||||
self.snap = None;
|
||||
self.hover.clear();
|
||||
self.drag.take()
|
||||
@@ -250,6 +285,10 @@ impl Tool for TransformTool {
|
||||
self.drag.is_some_and(|d| d.began)
|
||||
}
|
||||
|
||||
fn readout(&self, _cx: &ToolContext) -> Option<Readout> {
|
||||
self.readout.clone()
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
match ev.kind {
|
||||
@@ -294,10 +333,11 @@ impl Tool for TransformTool {
|
||||
}
|
||||
let (p, guides) = self.snap.as_ref().map_or((p, vec![]), |s| s.snap(cx, p));
|
||||
self.hover = guides;
|
||||
let (m, text) = self.matrix_in(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, p, ev.mods.shift, d.bounds);
|
||||
let (m, a, b) = self.solve(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, p, ev.mods.shift, d.bounds);
|
||||
(d.last, d.pointer, d.shift) = (m, p, ev.mods.shift);
|
||||
self.drag = Some(d);
|
||||
self.measure = Some((p, text));
|
||||
self.measure = Some((p, self.label(a, b)));
|
||||
self.readout = Some(self.readout_of(m, a, b, d.bounds));
|
||||
out.push(preview(m, ev.mods.alt, ev.backquote));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -133,6 +133,8 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
|
||||
drag_released(app, ui, &f, d, release_pos(ui, button).unwrap_or(p));
|
||||
}
|
||||
} else if let Some(p) = hover_tool
|
||||
// A press on an artboard's name starts no drag: the moves while it is held are not hover.
|
||||
&& !(pointer.primary_down() && ui.data(|d| d.get_temp::<(usize, bool)>(name_press_id()).is_some()))
|
||||
&& (pointer.is_moving() || mods_changed || !was_over || tool_switched || (context_changed && app.session.tool_wants_rehover()))
|
||||
{
|
||||
let ev = PointerEvent {
|
||||
|
||||
@@ -1027,6 +1027,49 @@ fn shift_clicking_canvas_artboard_label_toggles_multi_selection() {
|
||||
assert_eq!(app.session.tool_options()["selected"], json!([2]), "Plain click selects that artboard alone");
|
||||
}
|
||||
|
||||
/// A press on an artboard's name starts no drag. Moving with the button still held is not hover,
|
||||
/// so Shift over a gridline intersection does not position the Perspective Selection tool's plane
|
||||
/// until the button is up.
|
||||
#[test]
|
||||
fn moves_while_an_artboard_name_is_pressed_do_not_hover_the_tool() {
|
||||
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300, "artboards": 2})).unwrap();
|
||||
app.select_tool("perspectiveSelection");
|
||||
app.session.execute("perspective.grid.show", &json!({"visible": true})).unwrap();
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.center = Point::new(200.0, 150.0);
|
||||
v.zoom = 1.0;
|
||||
v.fitted = true;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let drawn = shapes(&mut app, &ctx);
|
||||
let label = drawn
|
||||
.iter()
|
||||
.find_map(|s| match s {
|
||||
Shape::Text(t) if t.galley.text() == "01 - Artboard 1" => Some(t.pos + vec2(10.0, 5.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let xf = Xf::new(rect, app.view().unwrap());
|
||||
let doc = |app: &VectorcraftApp| app.session.active().unwrap().doc.clone();
|
||||
let g = PerspectiveGrid::current(&doc(&app));
|
||||
let target = xf.to_screen(g.to_page(Plane::Left, Point::new(2.0 * g.cell, 3.0 * g.cell)).unwrap());
|
||||
assert!(rect.contains(label) && rect.contains(target), "{label:?} {target:?} {rect:?}");
|
||||
|
||||
let shift = egui::Modifiers::SHIFT;
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: shift };
|
||||
frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(shift), egui::Event::PointerMoved(label), button(label, true)]);
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(target)]);
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(target + vec2(0.5, 0.0))]);
|
||||
assert!(!PerspectiveGrid::positioning(&doc(&app)), "the button is still down");
|
||||
frame(&mut app, &ctx, vec![button(target, false)]);
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(target)]);
|
||||
assert!(PerspectiveGrid::positioning(&doc(&app)), "with the button up, Shift over the intersection positions the plane");
|
||||
}
|
||||
|
||||
/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the
|
||||
/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing.
|
||||
#[test]
|
||||
|
||||
@@ -18,13 +18,13 @@ mod hints;
|
||||
mod status_bar;
|
||||
mod tabs;
|
||||
|
||||
pub(crate) use control_bar::group_separator;
|
||||
pub use control_bar::{
|
||||
AnchorControls, CONTROL_BAR_EDGES, CONTROL_BAR_GROUPS, anchor_buttons, anchor_controls, control_bar, control_bar_moving, control_bar_placement,
|
||||
read_hidden_groups,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use control_bar::{CONTROL_BAR_FLOAT_AT, ISOLATION_BUTTON};
|
||||
pub(crate) use control_bar::{group_separator, left_out};
|
||||
#[cfg(test)]
|
||||
use hints::hint_segments;
|
||||
pub use hints::{hint_bar, hint_strings};
|
||||
|
||||
@@ -61,6 +61,7 @@ pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorCon
|
||||
for (label, buttons) in groups.into_iter().take(shown) {
|
||||
let text = egui::RichText::new(label).size(12.0).color(t.text);
|
||||
if in_row && ui.available_width() < row_width(ui, &[widgets::text_width(ui, text.clone())]) + buttons_width(ui, buttons.len()) {
|
||||
left_out(ui);
|
||||
break;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
@@ -164,9 +165,13 @@ const GLOBAL_EDIT_OPTIONS_WIDTH: f32 = 24.0;
|
||||
/// It opens the Properties panel's Global Edit Options, which set the same session options, and is
|
||||
/// off and closed while Global Edit is on, as that panel's dropdown is. It sits after Recolor
|
||||
/// Artwork, ahead of the X/Y/W/H fields that the bar leaves out when it runs short of room, and is
|
||||
/// left out when the room left, less `reserve` ([`transform_reserve`]), cannot hold it.
|
||||
/// left out when the room left, less `reserve` ([`transform_reserve`]), cannot hold it ([`left_out`]).
|
||||
fn global_edit_options(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
|
||||
if !crate::menus::enabled(app, "select.globalEdit") || ui.available_width() < row_width(ui, &[GLOBAL_EDIT_OPTIONS_WIDTH]) + reserve {
|
||||
if !crate::menus::enabled(app, "select.globalEdit") {
|
||||
return;
|
||||
}
|
||||
if ui.available_width() < row_width(ui, &[GLOBAL_EDIT_OPTIONS_WIDTH]) + reserve {
|
||||
left_out(ui);
|
||||
return;
|
||||
}
|
||||
let on = app.session.active().is_some_and(|d| d.global_edit.is_some());
|
||||
@@ -287,6 +292,24 @@ fn boundary(ui: &mut Ui, draw: impl FnOnce(&mut Ui)) {
|
||||
ui.data_mut(|d| d.insert_temp(key, b));
|
||||
}
|
||||
|
||||
/// The key of the pass in which a control of the row `ui` was last left out for room.
|
||||
fn left_out_key(ui: &Ui) -> egui::Id {
|
||||
ui.unique_id().with("cb-left-out")
|
||||
}
|
||||
|
||||
/// Notes that a control of the Control bar's row `ui` was left out in this pass for want of room,
|
||||
/// so the controls the bar leaves out first stay out too ([`any_left_out`]).
|
||||
pub(crate) fn left_out(ui: &mut Ui) {
|
||||
let (key, pass) = (left_out_key(ui), ui.ctx().cumulative_pass_nr());
|
||||
ui.data_mut(|d| d.insert_temp(key, pass));
|
||||
}
|
||||
|
||||
/// Whether a control of the row `ui` was left out in this pass for want of room ([`left_out`]).
|
||||
fn any_left_out(ui: &Ui) -> bool {
|
||||
let (key, pass) = (left_out_key(ui), ui.ctx().cumulative_pass_nr());
|
||||
ui.data(|d| d.get_temp::<u64>(key)) == Some(pass)
|
||||
}
|
||||
|
||||
/// The separator that ends a group of the Control bar's controls, drawn as `ui.separator()` draws
|
||||
/// one, and left out when no control follows it ([`boundary`]) or the row has no room left for it.
|
||||
pub(crate) fn group_separator(ui: &mut Ui) {
|
||||
@@ -348,20 +371,26 @@ fn transform_reserve(app: &VectorcraftApp, ui: &Ui, selected: bool) -> f32 {
|
||||
/// Align To menu and, for two or more objects, the Distribute buttons (a single object keeps the bar
|
||||
/// as it was); each shows only while the end of the bar keeps its room. Groups the panel menu has
|
||||
/// off are left out, and take no room. Without `room` (the Character group had none), all of these
|
||||
/// groups are left out, so the bar trims them from its right end before the Character group.
|
||||
/// groups are left out, so the bar trims them from its right end before the Character group. The
|
||||
/// Character group without its room, and a control left out for room here, are noted with
|
||||
/// [`left_out`].
|
||||
fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize, room: bool) -> bool {
|
||||
if !room {
|
||||
left_out(ui);
|
||||
}
|
||||
let nothing = objects == 0;
|
||||
let end = end_width(app, ui, nothing);
|
||||
if room && group_on(app, "paragraph") && crate::panels::paragraph::control_bar(app, ui, BUTTON, |ui| group_end_width(ui) + end) {
|
||||
group_end(ui);
|
||||
}
|
||||
if room
|
||||
&& group_on(app, "verticalAlignment")
|
||||
&& crate::panels::properties::vertical_align::shown(app).is_some()
|
||||
&& ui.available_width() >= buttons_width(ui, 4) + group_end_width(ui) + end
|
||||
&& crate::panels::properties::vertical_align::buttons(app, ui, BUTTON)
|
||||
{
|
||||
group_end(ui);
|
||||
if room && group_on(app, "verticalAlignment") && crate::panels::properties::vertical_align::shown(app).is_some() {
|
||||
if ui.available_width() >= buttons_width(ui, 4) + group_end_width(ui) + end {
|
||||
if crate::panels::properties::vertical_align::buttons(app, ui, BUTTON) {
|
||||
group_end(ui);
|
||||
}
|
||||
} else {
|
||||
left_out(ui);
|
||||
}
|
||||
}
|
||||
if nothing {
|
||||
// Each button shows only while the bar has room left for it, so Preferences can show
|
||||
@@ -378,14 +407,24 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize
|
||||
}
|
||||
let transform = group_on(app, "transform");
|
||||
let end = if transform { separator_width(ui) + end } else { 0.0 };
|
||||
if room && group_on(app, "alignDistribute") && ui.available_width() >= buttons_width(ui, 7) + end {
|
||||
if room && group_on(app, "alignDistribute") {
|
||||
if ui.available_width() < buttons_width(ui, 7) + end {
|
||||
left_out(ui);
|
||||
return transform;
|
||||
}
|
||||
crate::panels::align::align_buttons(app, ui, BUTTON);
|
||||
if ui.available_width() >= crate::panels::align::align_to_menu_width(BUTTON) + ui.spacing().item_spacing.x + end {
|
||||
crate::panels::align::align_to_menu(app, ui, BUTTON);
|
||||
if objects > 1 && ui.available_width() >= GROUP_GAP + buttons_width(ui, 6) + end {
|
||||
ui.add_space(GROUP_GAP);
|
||||
crate::panels::align::distribute_buttons(app, ui, BUTTON);
|
||||
if objects > 1 {
|
||||
if ui.available_width() >= GROUP_GAP + buttons_width(ui, 6) + end {
|
||||
ui.add_space(GROUP_GAP);
|
||||
crate::panels::align::distribute_buttons(app, ui, BUTTON);
|
||||
} else {
|
||||
left_out(ui);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
left_out(ui);
|
||||
}
|
||||
if transform {
|
||||
boundary(ui, separator);
|
||||
@@ -594,9 +633,10 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
|
||||
/// and the width profile, and the separator after them while a control follows ([`group_separator`]).
|
||||
/// Short of room, the bar leaves out the width profile first, then the Stroke link with the weight,
|
||||
/// then the two chips; each shows only while the bar has room for all of it and `reserve` after it
|
||||
/// ([`transform_reserve`]).
|
||||
/// ([`transform_reserve`]), and a control left out is noted with [`left_out`].
|
||||
fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
|
||||
if ui.available_width() < row_width(ui, &[CHIP_WIDTH, CHIP_WIDTH]) + reserve {
|
||||
left_out(ui);
|
||||
return;
|
||||
}
|
||||
let shown_stroke = crate::panels::current_stroke(app);
|
||||
@@ -610,11 +650,13 @@ fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
|
||||
if ui.available_width() >= row_width(ui, &[link, WEIGHT_FIELD_WIDTH]) + reserve {
|
||||
stroke_panel::link(app, ui, tl!("Stroke:"));
|
||||
stroke_panel::weight_field(app, ui, "cb-stroke", weight, WEIGHT_FIELD_WIDTH);
|
||||
if ui.available_width() >= row_width(ui, &[stroke_panel::PROFILE_DROPDOWN_WIDTH]) + reserve
|
||||
&& let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref()))
|
||||
{
|
||||
if ui.available_width() < row_width(ui, &[stroke_panel::PROFILE_DROPDOWN_WIDTH]) + reserve {
|
||||
left_out(ui);
|
||||
} else if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) {
|
||||
app.run("stroke.set", json!({"profile": id})).ok();
|
||||
}
|
||||
} else {
|
||||
left_out(ui);
|
||||
}
|
||||
group_end(ui);
|
||||
}
|
||||
@@ -625,7 +667,7 @@ fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
|
||||
/// the bar leaves out Global Edit Options first, then Recolor Artwork, then the Style link with its
|
||||
/// chip. The Opacity link with its field and the Style link with its chip each show only while the
|
||||
/// bar has room for all of it and `reserve` after it ([`transform_reserve`]), so the narrower Style
|
||||
/// part can show without the Opacity part.
|
||||
/// part can show without the Opacity part. A control left out is noted with [`left_out`].
|
||||
fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: f32, selected: bool, reserve: f32) {
|
||||
let (opacity_on, style_on) = (group_on(app, "opacity"), group_on(app, "graphicStyle") && selected);
|
||||
let mut drawn = false;
|
||||
@@ -642,10 +684,15 @@ fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity:
|
||||
app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
|
||||
}
|
||||
drawn = true;
|
||||
} else {
|
||||
left_out(ui);
|
||||
}
|
||||
}
|
||||
let text = egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline();
|
||||
if style_on && ui.available_width() >= GROUP_GAP + row_width(ui, &[widgets::text_width(ui, text.clone()), CHIP_WIDTH]) + reserve {
|
||||
let style_room = GROUP_GAP + row_width(ui, &[widgets::text_width(ui, text.clone()), CHIP_WIDTH]) + reserve;
|
||||
if style_on && ui.available_width() < style_room {
|
||||
left_out(ui);
|
||||
} else if style_on {
|
||||
// Style picker: the selection's graphic style; its menu applies another.
|
||||
ui.add_space(GROUP_GAP);
|
||||
if ui.link(text).clicked() {
|
||||
@@ -656,10 +703,9 @@ fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity:
|
||||
egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui));
|
||||
// Recolor Artwork, when the selection has colours to recolour.
|
||||
let colored = app.session.active().is_some_and(|st| crate::canvas::has_colors(&st.doc, &st.selection.objects));
|
||||
if colored
|
||||
&& ui.available_width() >= buttons_width(ui, 1) + reserve
|
||||
&& widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, BUTTON).clicked()
|
||||
{
|
||||
if colored && ui.available_width() < buttons_width(ui, 1) + reserve {
|
||||
left_out(ui);
|
||||
} else if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, BUTTON).clicked() {
|
||||
crate::menus::invoke(app, "ui.recolorDialog", json!({}));
|
||||
}
|
||||
global_edit_options(app, ui, reserve);
|
||||
@@ -670,6 +716,58 @@ fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity:
|
||||
}
|
||||
}
|
||||
|
||||
/// The width of the chevron that opens the Select Similar Options menu.
|
||||
const SELECT_SIMILAR_MENU_WIDTH: f32 = 16.0;
|
||||
|
||||
/// The label of each attribute of [`vectorcraft_engine::cmd::SIMILAR`] in the Select Similar Options
|
||||
/// menu, translated.
|
||||
fn similar_label(by: &str) -> &'static str {
|
||||
match by {
|
||||
"all" => tl!("All"),
|
||||
"fillColor" => tl!("Fill Color"),
|
||||
"fillAndStroke" => tl!("Fill & Stroke"),
|
||||
"strokeColor" => tl!("Stroke Color"),
|
||||
"strokeWeight" => tl!("Stroke Weight"),
|
||||
"opacity" => tl!("Opacity"),
|
||||
"blendingMode" => tl!("Blending Mode"),
|
||||
"appearance" => tl!("Appearance"),
|
||||
"graphicStyle" => tl!("Graphic Style"),
|
||||
"shapeType" => tl!("Shape"),
|
||||
_ => tl!("Symbol Instance"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Control bar's Select Similar Objects button and, beside it, the Select Similar Options menu
|
||||
/// (<https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=165>). The
|
||||
/// button runs `select.similar`, which selects the objects like the first selected one by the
|
||||
/// attribute kept for the session; a menu item runs it by that item's attribute, which is kept from
|
||||
/// then on, and the kept one is checked. Both are off while `select.similar` cannot run. They show
|
||||
/// together at the right end of the bar, after a separator, while something is selected and the
|
||||
/// panel menu has Object Options on, and only in the room every control ahead of them left
|
||||
/// ([`contents`]): the bar leaves them out before any other control, and while a control ahead of
|
||||
/// them was left out for room ([`any_left_out`]). Groups the panel menu has off take no room.
|
||||
fn trailing_select_similar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty());
|
||||
let width = separator_width(ui) + row_width(ui, &[BUTTON, SELECT_SIMILAR_MENU_WIDTH]);
|
||||
if any_left_out(ui) || !selected || !group_on(app, "objectOptions") || ui.available_width() < width {
|
||||
return;
|
||||
}
|
||||
boundary(ui, separator);
|
||||
let enabled = crate::menus::enabled(app, "select.similar");
|
||||
if widgets::icon_button_enabled(ui, "copy", tl!("Select Similar Objects"), false, enabled, BUTTON).clicked() {
|
||||
crate::menus::invoke(app, "select.similar", json!({}));
|
||||
}
|
||||
let options = widgets::icon_button_enabled(ui, "chevron-down", tl!("Select Similar Options"), false, enabled, SELECT_SIMILAR_MENU_WIDTH);
|
||||
egui::Popup::menu(&options).show(|ui| {
|
||||
ui.set_min_width(140.0);
|
||||
for (by, _) in vectorcraft_engine::cmd::SIMILAR {
|
||||
if widgets::menu_item(ui, similar_label(by), true, app.session.select_similar == by) {
|
||||
crate::menus::invoke(app, "select.similar", json!({ "by": by }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Control bar's gripper, at the left end of the bar `bar`. Dragging it shows where a release
|
||||
/// puts the bar ([`release_at`] in `dock_room`, the window below the menu bar): a band along the
|
||||
/// edge it docks to, or the outline of the bar floating under the pointer. The bar stays where it is
|
||||
@@ -819,6 +917,8 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
|
||||
let label = egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text);
|
||||
if ui.available_width() >= widgets::text_width(ui, label.clone()) {
|
||||
ui.label(label);
|
||||
} else {
|
||||
left_out(ui);
|
||||
}
|
||||
// With the Artboard tool in use, the bar shows the active artboard's controls in place
|
||||
// of the object controls.
|
||||
@@ -862,6 +962,8 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
|
||||
// all; left out, it leaves out the groups between it and the link too.
|
||||
let room = !group_on(app, "character") || crate::panels::character::control_bar(app, ui, reserve);
|
||||
if !groups_before_transform(app, ui, sel.len(), room) {
|
||||
// With the Transform group off, its link and X/Y/W/H fields take no room.
|
||||
trailing_select_similar(app, ui);
|
||||
erase_trailing(ui);
|
||||
return;
|
||||
}
|
||||
@@ -875,9 +977,10 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
|
||||
}
|
||||
crate::panels::transform::link(app, ui);
|
||||
// The bounding box, rotated with rotated objects: its centre and its own sides.
|
||||
if let Some(b) = app.selection_box()
|
||||
&& ui.available_width() >= inline_transform_width(ui, t)
|
||||
{
|
||||
let selection_box = app.selection_box();
|
||||
if selection_box.is_some() && ui.available_width() < inline_transform_width(ui, t) {
|
||||
left_out(ui);
|
||||
} else if let Some(b) = selection_box {
|
||||
let c = b.center();
|
||||
let origin = app.ruler_origin();
|
||||
let link = crate::panels::transform::proportional(app, ui);
|
||||
@@ -906,6 +1009,8 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
|
||||
crate::panels::transform::constrain_link_at(app, ui, ui.interact(room, id, Sense::click()));
|
||||
}
|
||||
}
|
||||
// Select Similar Objects comes last: it takes only the room every control ahead of it left.
|
||||
trailing_select_similar(app, ui);
|
||||
erase_trailing(ui);
|
||||
});
|
||||
// Controls that keep their size can still run into the button's room in a narrow window;
|
||||
|
||||
@@ -3502,6 +3502,162 @@ fn the_control_bar_shows_the_global_edit_options() {
|
||||
assert!(seen, "a wide bar has room for it");
|
||||
}
|
||||
|
||||
/// The Control bar's Select Similar Objects button selects the objects like the first selected one
|
||||
/// by the attribute kept for the session (All at first), and the Select Similar Options menu beside
|
||||
/// it selects by the item chosen, which is kept and checked from then on. Both show with something
|
||||
/// selected while the Object Options group is on, and fit inside the bar or are left out.
|
||||
#[test]
|
||||
fn the_control_bar_selects_similar_objects() {
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let rect = |app: &mut crate::VectorcraftApp, x: f64, color: &str| {
|
||||
let id = app.run("shape.rectangle", json!({"x": x, "y": 20, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [id], "color": color})).unwrap();
|
||||
id
|
||||
};
|
||||
let a = rect(&mut app, 20.0, "#ff0000");
|
||||
let b = rect(&mut app, 100.0, "#ff0000");
|
||||
let c = rect(&mut app, 200.0, "#0000ff");
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(2400.0, 300.0));
|
||||
let frame = |app: &mut crate::VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default()
|
||||
};
|
||||
type Nodes = Vec<(egui::accesskit::NodeId, egui::accesskit::Node)>;
|
||||
let find = |nodes: &Nodes, name: &str| nodes.iter().find(|(_, n)| n.label() == Some(name) || n.value() == Some(name)).map(|(_, n)| n.clone());
|
||||
// A menu item's label carries its check mark or the room for it.
|
||||
let item = |nodes: &Nodes, name: &str| {
|
||||
nodes.iter().find(|(_, n)| n.label().or(n.value()).is_some_and(|t| t.trim_start_matches(['✓', ' ']) == name)).map(|(_, n)| n.clone())
|
||||
};
|
||||
let center = |n: &egui::accesskit::Node| {
|
||||
let b = n.bounds().unwrap_or_else(|| panic!("{n:?}"));
|
||||
egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
|
||||
};
|
||||
let click = |app: &mut crate::VectorcraftApp, at: egui::Pos2| {
|
||||
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, vec![egui::Event::PointerMoved(at), press(true)]);
|
||||
frame(app, vec![press(false)]);
|
||||
frame(app, vec![]);
|
||||
frame(app, vec![])
|
||||
};
|
||||
let selected = |app: &crate::VectorcraftApp| {
|
||||
let mut ids: Vec<u64> = app.session.active().unwrap().selection.objects.iter().map(|i| i.0).collect();
|
||||
ids.sort();
|
||||
ids
|
||||
};
|
||||
app.run("select.set", json!({"ids": []})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
let nodes = frame(&mut app, vec![]);
|
||||
assert!(find(&nodes, "Select Similar Objects").is_none() && find(&nodes, "Select Similar Options").is_none(), "nothing selected");
|
||||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
let nodes = frame(&mut app, vec![]);
|
||||
let button = find(&nodes, "Select Similar Objects").unwrap_or_else(|| panic!("one rectangle: the button shows"));
|
||||
click(&mut app, center(&button));
|
||||
assert_eq!(selected(&app), vec![a, b], "All: the red rectangles");
|
||||
// The menu selects by the item chosen and keeps it.
|
||||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
let options = find(&frame(&mut app, vec![]), "Select Similar Options").unwrap_or_else(|| panic!("the options menu shows"));
|
||||
let nodes = click(&mut app, center(&options));
|
||||
for label in [
|
||||
"All",
|
||||
"Fill Color",
|
||||
"Fill & Stroke",
|
||||
"Stroke Color",
|
||||
"Stroke Weight",
|
||||
"Opacity",
|
||||
"Blending Mode",
|
||||
"Appearance",
|
||||
"Graphic Style",
|
||||
"Shape",
|
||||
"Symbol Instance",
|
||||
] {
|
||||
assert!(item(&nodes, label).is_some(), "{label} in the menu");
|
||||
}
|
||||
assert!(find(&nodes, "✓ All").is_some(), "All is checked at first");
|
||||
let stroke = item(&nodes, "Stroke Color").unwrap_or_else(|| panic!("Stroke Color"));
|
||||
click(&mut app, center(&stroke));
|
||||
assert_eq!(app.session.select_similar, "strokeColor");
|
||||
assert_eq!(selected(&app), vec![a, b, c], "Stroke Color: all three share the default stroke");
|
||||
app.run("select.set", json!({"ids": [c]})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
let nodes = frame(&mut app, vec![]);
|
||||
click(&mut app, center(&find(&nodes, "Select Similar Objects").unwrap_or_else(|| panic!("the button"))));
|
||||
assert_eq!(selected(&app), vec![a, b, c], "the button keeps Stroke Color");
|
||||
let options = find(&frame(&mut app, vec![]), "Select Similar Options").unwrap_or_else(|| panic!("the options menu"));
|
||||
let nodes = click(&mut app, center(&options));
|
||||
assert!(find(&nodes, "✓ Stroke Color").is_some(), "the kept item is checked");
|
||||
// The panel menu's Object Options group turns them off.
|
||||
app.run("window.control.options", json!({"group": "objectOptions"})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
assert!(find(&frame(&mut app, vec![]), "Select Similar Objects").is_none(), "Object Options off");
|
||||
app.run("window.control.options", json!({"group": "objectOptions"})).unwrap();
|
||||
// At any width the button and its menu fit inside the bar together or are left out. They take
|
||||
// only the room left at the end of the bar, so they show only while the Align buttons and the
|
||||
// inline X field do too. A control ahead of them that appears in a wider bar (the Distribute
|
||||
// buttons) can take that room again, so the widest bar is the one that must show them.
|
||||
let mut seen = false;
|
||||
for width in (600..=2400).step_by(10) {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width as f32, 300.0));
|
||||
let mut nodes = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
nodes = out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default();
|
||||
}
|
||||
let shown = find(&nodes, "Select Similar Objects").and_then(|n| n.bounds());
|
||||
let menu = find(&nodes, "Select Similar Options").and_then(|n| n.bounds());
|
||||
assert_eq!(shown.is_some(), menu.is_some(), "{width}: the button and its menu show together");
|
||||
if let Some(b) = menu {
|
||||
assert!(b.x1 <= f64::from(width), "{width}: {b:?}");
|
||||
assert!(find(&nodes, "Horizontal Align Left").is_some(), "{width}: shown without the Align buttons");
|
||||
let x = nodes.iter().find(|(_, n)| n.label() == Some("X:") || n.value() == Some("X:")).and_then(|(_, n)| n.bounds());
|
||||
assert!(x.is_some_and(|x| x.x1 <= b.x0), "{width}: shown without the X field ahead of it: {x:?}");
|
||||
}
|
||||
seen = shown.is_some();
|
||||
}
|
||||
assert!(seen, "the widest bar has room for it");
|
||||
// With the Transform group off they still show at the end of a wide bar, after the Align
|
||||
// buttons.
|
||||
app.run("window.control.options", json!({"group": "transform", "visible": false})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
let nodes = frame(&mut app, vec![]);
|
||||
let (button, align) = (find(&nodes, "Select Similar Objects"), find(&nodes, "Horizontal Align Left"));
|
||||
let (Some(b), Some(a)) = (button.and_then(|n| n.bounds()), align.and_then(|n| n.bounds())) else {
|
||||
panic!("Transform off: the button and the Align buttons show")
|
||||
};
|
||||
assert!(a.x1 <= b.x0, "after the Align buttons: {a:?} {b:?}");
|
||||
// With the Transform group off, its link and X/Y/W/H fields take no room: the button and its
|
||||
// menu show in the narrowest bar that holds them after every control ahead of them, and never
|
||||
// while a control ahead of them is left out.
|
||||
let mut first = None;
|
||||
for width in (600..=2400).step_by(10) {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width as f32, 300.0));
|
||||
let mut nodes = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
nodes = out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default();
|
||||
}
|
||||
let Some(b) = find(&nodes, "Select Similar Options").and_then(|n| n.bounds()) else {
|
||||
continue;
|
||||
};
|
||||
for name in ["Stroke:", "Opacity:", "Horizontal Align Left", "Recolor Artwork"] {
|
||||
assert!(find(&nodes, name).is_some(), "{width}: shown without {name} ahead of it");
|
||||
}
|
||||
first.get_or_insert((width, b.x1));
|
||||
}
|
||||
let Some((width, end)) = first else { panic!("Transform off: a wide bar has room for them") };
|
||||
let free = f64::from(width) - end;
|
||||
assert!(free < 120.0, "Transform off: the narrowest bar that shows them, {width} pt wide, leaves {free} pt after them");
|
||||
}
|
||||
|
||||
/// At any width the Control bar shows the inline X, Y, W and H fields whole inside its right margin,
|
||||
/// or leaves all four out and keeps the Transform link; a wider bar never leaves them out once a
|
||||
/// narrower one showed them. The Align buttons, and for two objects the Distribute buttons, are
|
||||
|
||||
@@ -84,6 +84,24 @@ pub fn offer_place(app: &mut VectorcraftApp, p: &Value) -> bool {
|
||||
offer(app, &name, &bytes, path, Some(p))
|
||||
}
|
||||
|
||||
/// The `dxf` options a drawing `file.place` params `p` name is placed with when Show Import Options
|
||||
/// is off: the choices confirmed last (fit, lineweights, centring, layers), the drawing at 1:1 in
|
||||
/// its own unit. `None` when `p` names no `.dxf` drawing or already gives `dxf` options.
|
||||
pub fn last_options(app: &VectorcraftApp, p: &Value) -> Option<Value> {
|
||||
let named = p.get("path").or_else(|| p.get("name")).and_then(Value::as_str).map(fileio::extension);
|
||||
if p.get("dxf").is_some() || named.as_deref() != Some("dxf") {
|
||||
return None;
|
||||
}
|
||||
Some(remembered(app))
|
||||
}
|
||||
|
||||
/// The `dxf` options of the choices confirmed last (fit, lineweights, centring, layers; `{}` when
|
||||
/// none was).
|
||||
pub fn remembered(app: &VectorcraftApp) -> Value {
|
||||
let last = app.ui.dxf_import.as_object();
|
||||
Value::Object(REMEMBERED.iter().filter_map(|k| last?.get(*k).filter(|v| v.is_boolean()).map(|v| (k.to_string(), v.clone()))).collect())
|
||||
}
|
||||
|
||||
/// The `dxf` options of `document.open` and `file.place` the fields set.
|
||||
fn options(d: &Dialog) -> Value {
|
||||
let fit = d.bool("fit");
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! Fields: `mode` (`open` | `place`), `name`, `path?`, `pageCount`, `page` (the page shown, and the
|
||||
//! one placed; 1-based), `allPages`, `range` and `linkPages` (open: Import PDF Pages as Links, for
|
||||
//! a file read from a path and opened without a password), `cropTo` and `password`. `__locked` is set
|
||||
//! until the password is accepted: OK then unlocks the file. `__place` keeps the other `file.place`
|
||||
//! until the password is accepted: OK then unlocks the file, and with `__passwordOnly` (Place with
|
||||
//! Show Import Options off) places its first page at once. `__place` keeps the other `file.place`
|
||||
//! params (Link, Template, `at`…). The file's bytes stay out of the fields, in
|
||||
//! [`crate::state::UiState::dialog_file`].
|
||||
|
||||
@@ -168,6 +169,33 @@ pub fn offer_place(app: &mut VectorcraftApp, p: &Value) -> bool {
|
||||
offer(app, &name, &bytes, path, Some(p))
|
||||
}
|
||||
|
||||
/// [`offer_place`] when Show Import Options is off: asks (true) only for the password of an
|
||||
/// encrypted PDF given no `password`, which nothing else opens, and places its first page (or
|
||||
/// `page`) and crop box once it is unlocked; any other PDF is placed that way without asking.
|
||||
pub fn offer_place_password(app: &mut VectorcraftApp, p: &Value) -> bool {
|
||||
// A `page` that isn't a page number is left for `file.place` to refuse.
|
||||
if p.get("password").is_some() || p.get("page").is_some_and(|v| v.as_u64().is_none_or(|n| n == 0)) {
|
||||
return false;
|
||||
}
|
||||
let Some((name, bytes, path)) = place_source(app, p, |ext| matches!(ext, "pdf" | "ai" | "ait")) else { return false };
|
||||
if !matches!(vectorcraft_pdf::open_pdf(&bytes, None).map(|_| ()), Err(PdfError::NeedsPassword)) {
|
||||
return false;
|
||||
}
|
||||
let offered = offer(app, &name, &bytes, path, Some(p));
|
||||
if let (true, Some(d)) = (offered, app.ui.dialog.as_mut()) {
|
||||
d.fields.insert("__passwordOnly".into(), json!(true));
|
||||
// A `page` and `crop` given are placed as given, for `file.place` to check.
|
||||
if let Some(place) = d.fields.get_mut("__place").and_then(Value::as_object_mut) {
|
||||
for k in ["page", "crop"] {
|
||||
if let Some(v) = p.get(k) {
|
||||
place.insert(k.into(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
offered
|
||||
}
|
||||
|
||||
fn page_count(d: &Dialog) -> usize {
|
||||
d.fields.get("pageCount").and_then(Value::as_u64).unwrap_or(0) as usize
|
||||
}
|
||||
@@ -204,7 +232,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let info = vectorcraft_pdf::info(&file.bytes, password(&d).as_deref()).map_err(|e| e.to_string())?;
|
||||
d.fields.insert("__locked".into(), json!(false));
|
||||
d.fields.insert("pageCount".into(), json!(info.pages.len()));
|
||||
if info.pages.len() > 1 {
|
||||
if info.pages.len() > 1 && !d.bool("__passwordOnly") {
|
||||
// Unlocked: now pick the pages.
|
||||
app.ui.dialog = Some(d);
|
||||
return Ok(Value::Null);
|
||||
@@ -214,8 +242,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string);
|
||||
let r = if d.str("mode") == "place" {
|
||||
let mut p = with_file(d.fields.get("__place"), &d, &file);
|
||||
p["page"] = json!(page(&d));
|
||||
p["crop"] = json!(crop(&d).id());
|
||||
// Asked only for the password: a `page` and `crop` given with the file stand.
|
||||
let [page_given, crop_given] = ["page", "crop"].map(|k| d.bool("__passwordOnly") && p.get(k).is_some());
|
||||
if !page_given {
|
||||
p["page"] = json!(page(&d));
|
||||
}
|
||||
if !crop_given {
|
||||
p["crop"] = json!(crop(&d).id());
|
||||
}
|
||||
if let Some(pw) = password(&d) {
|
||||
p["password"] = json!(pw);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! File → Place: the files picked, one line each about what they are, and the Place options row
|
||||
//! (Link, Template, Replace). OK places one file centred in the view (or in place of the selected
|
||||
//! object) or loads the place cursor with several ([`crate::place::confirm`]).
|
||||
//! (Link, Template, Replace, Show Import Options). OK places one file centred in the view (or in
|
||||
//! place of the selected object) or loads the place cursor with several ([`crate::place::confirm`]).
|
||||
//!
|
||||
//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (one file
|
||||
//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, `showImportOptions` (each
|
||||
//! PDF, DXF or text file asks for its import options before it is placed), and `__replace` (one file
|
||||
//! and one selected object: Replace applies), `__documents` and `__others` (VectorCraft documents
|
||||
//! from files are picked, other files are: what Link's tooltip explains), `__info` (a line per
|
||||
//! file; on the desktop the lines are read off the UI thread and are empty until they are read,
|
||||
@@ -29,10 +30,15 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
};
|
||||
|
||||
/// The options row: (field, label, tooltip).
|
||||
const OPTIONS: [(&str, &str, &str); 3] = [
|
||||
const OPTIONS: [(&str, &str, &str); 4] = [
|
||||
("link", "Link", "Keep a link to an image file instead of only embedding it"),
|
||||
("template", "Template", "Place onto a new locked, dimmed template layer below the current layer"),
|
||||
("replace", "Replace", "Swap the selected object for the file, keeping its place and transform"),
|
||||
(
|
||||
"showImportOptions",
|
||||
"Show Import Options",
|
||||
"Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last",
|
||||
),
|
||||
];
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
@@ -73,7 +79,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
// The options row.
|
||||
ui.horizontal(|ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 18.0;
|
||||
for (key, label, tip) in OPTIONS {
|
||||
let on = d.bool(key);
|
||||
|
||||
@@ -37,10 +37,12 @@ pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let id = d.str("__effect");
|
||||
let defaults = vectorcraft_effects::default_params(&id).unwrap_or(json!({}));
|
||||
// Reset all resets the shape, view and light; the mapped graphics stay.
|
||||
// Reset all resets the shape, view and light; the mapped graphics and the render
|
||||
// settings stay.
|
||||
let defaults = match defaults {
|
||||
Value::Object(mut m) => {
|
||||
m.remove(vectorcraft_effects::GRAPHICS);
|
||||
m.remove(vectorcraft_effects::RENDER);
|
||||
Value::Object(m)
|
||||
}
|
||||
other => other,
|
||||
|
||||
@@ -299,14 +299,17 @@ fn code_view(app: &VectorcraftApp, ui: &mut egui::Ui, d: &Dialog) {
|
||||
Some((_, c)) => c,
|
||||
None => {
|
||||
const MAX: usize = 200_000;
|
||||
let params = json!({ "svg": opts });
|
||||
let encoded = vectorcraft_engine::cmd::fileio::check_linked_scripts("document.export", "svg", st).and_then(|()| {
|
||||
let params = json!({ "svg": opts });
|
||||
if selection {
|
||||
let (doc, _) = vectorcraft_engine::cmd::fileio::selection_doc(st, "document.exportSelection", false)?;
|
||||
vectorcraft_engine::cmd::fileio::encode_all(&doc, "svg", ¶ms)
|
||||
let selected;
|
||||
let doc = if selection {
|
||||
selected = vectorcraft_engine::cmd::fileio::selection_doc(st, "document.exportSelection", false)?.0;
|
||||
&selected
|
||||
} else {
|
||||
vectorcraft_engine::cmd::fileio::encode_all(&st.doc, "svg", ¶ms)
|
||||
}
|
||||
&st.doc
|
||||
};
|
||||
vectorcraft_engine::cmd::fileio::check_linked_images("document.export", "svg", st, doc, ¶ms)?;
|
||||
vectorcraft_engine::cmd::fileio::encode_all(doc, "svg", ¶ms)
|
||||
});
|
||||
let mut text = match encoded {
|
||||
// Shown as text whatever the file's encoding.
|
||||
@@ -446,6 +449,52 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
/// Show Code under read roots draws no SVG that would name a linked image's file outside them
|
||||
/// (Images: Link): it shows the error, which names no file or folder. Embedded pixels are
|
||||
/// shown, and without roots the link is shown.
|
||||
#[test]
|
||||
fn show_code_leaves_out_a_linked_image_file_outside_the_read_roots() {
|
||||
let tmp = std::env::temp_dir();
|
||||
let root = tmp.join(format!("vc-svg-image-root-{}", std::process::id()));
|
||||
let away = tmp.join(format!("vc-svg-image-away-{}", std::process::id()));
|
||||
for dir in [&root, &away] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let photo = away.join("photo.png").to_string_lossy().into_owned();
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 40, 40, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
std::fs::write(&photo, png).unwrap();
|
||||
let (mut app, _) = app("/tmp/code.svg");
|
||||
app.run("file.place", json!({"path": photo, "link": true, "at": [50, 50]})).unwrap();
|
||||
app.run("file.export.svg", Value::Null).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("showCode".into(), json!(true));
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap().unwrap();
|
||||
// The text the code view shows after two frames in one context, with `roots` in force.
|
||||
let code = |app: &mut VectorcraftApp, roots: Option<&Arc<vectorcraft_engine::file_access::AutomationRoots>>| {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
for _ in 0..2 {
|
||||
let mut out =
|
||||
vectorcraft_engine::file_access::confine(roots, || ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
ctx.data(|m| m.get_temp::<(u64, Arc<String>)>(egui::Id::unique("svg-code"))).map(|(_, c)| c.to_string()).unwrap_or_default()
|
||||
};
|
||||
let embedded = code(&mut app, Some(&roots));
|
||||
assert!(embedded.contains("data:image/png;base64,") && !embedded.contains(&photo), "{embedded}");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("images".into(), json!("link"));
|
||||
let confined = code(&mut app, Some(&roots));
|
||||
assert!(confined.contains("a linked image file is outside the read roots"), "{confined}");
|
||||
assert!(!confined.contains("photo.png") && !confined.contains("vc-svg-image"), "{confined}");
|
||||
let shown = code(&mut app, None);
|
||||
assert!(shown.contains(&format!("xlink:href=\"{photo}\"")), "{shown}");
|
||||
for dir in [root, away] {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show Code under Export Selection shows the SVG that OK writes: the selection alone, cropped
|
||||
/// to it with what its effects draw, not the whole document.
|
||||
#[test]
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//!
|
||||
//! Fields: `conflicts` (as `clipboard.conflicts` lists them: `[{name, document, clipboard, spot}]`),
|
||||
//! `index` (the one asked about), `choice` (`merge` or `add`), `applyToAll`, `choices` (the
|
||||
//! answers so far, by swatch name), `__command` and `__params` (the paste to run).
|
||||
//! answers so far, by swatch name), `__command` and `__params` (the paste to run), and `__doc`
|
||||
//! (the uid of the document it asks about).
|
||||
|
||||
use egui::vec2;
|
||||
use serde_json::{Map, Value, json};
|
||||
@@ -39,7 +40,7 @@ pub fn ask(app: &mut VectorcraftApp, command: &str, params: &Value) -> Option<Di
|
||||
let conflicts = found["swatches"].as_array().filter(|a| !a.is_empty())?.clone();
|
||||
let fields = json!({
|
||||
"conflicts": conflicts, "index": 0, "choice": "merge", "applyToAll": false, "choices": {},
|
||||
"__command": command, "__params": params,
|
||||
"__command": command, "__params": params, DOC: app.session.active().map(|doc| doc.uid),
|
||||
});
|
||||
if app.ui.dialog.as_ref().is_some_and(|d| d.kind == KIND) {
|
||||
crate::placing::cancel_swatch_paste(app);
|
||||
@@ -48,6 +49,9 @@ pub fn ask(app: &mut VectorcraftApp, command: &str, params: &Value) -> Option<Di
|
||||
Some(Ok(json!({ "dialog": KIND })))
|
||||
}
|
||||
|
||||
/// The field that holds the uid of the document the question is about.
|
||||
const DOC: &str = "__doc";
|
||||
|
||||
fn conflicts(d: &Dialog) -> &[Value] {
|
||||
d.fields.get("conflicts").and_then(Value::as_array).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
@@ -102,8 +106,14 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Record the answer; ask about the next conflict, or paste once every one is answered.
|
||||
/// Record the answer; ask about the next conflict, or paste once every one is answered. When
|
||||
/// another document has become the active one since an immediate paste asked, it closes as Cancel
|
||||
/// does and pastes nothing (a deferred paste goes back to its own document).
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
|
||||
if d.fields.get("__deferredPaste").is_none() && d.fields.get(DOC).and_then(Value::as_u64) != app.session.active().map(|doc| doc.uid) {
|
||||
app.ui.dialog = None;
|
||||
return Ok(json!({}));
|
||||
}
|
||||
let choice = d.str("choice");
|
||||
if !matches!(choice.as_str(), "merge" | "add") {
|
||||
return Err(format!("choice must be \"merge\" or \"add\", not `{choice}`"));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Text Import Options: placing a text file asks how to read it (platform and character set) and
|
||||
//! what to clean up (line breaks inside paragraphs, blank lines between them, runs of spaces). OK
|
||||
//! places it as area type (`file.place` with `text`).
|
||||
//! places it as area type (`file.place` with `text`) and remembers the options: the next text file
|
||||
//! starts from them, and is placed with them when Show Import Options is off.
|
||||
//!
|
||||
//! Fields: `platform` (`windows` | `mac`), `characterSet` (`unicode` | `ansi`),
|
||||
//! `removeLineReturns`, `removeParagraphReturns`, `replaceSpaces` (on or off), `spaces` (how many
|
||||
@@ -27,9 +28,10 @@ pub fn is_text(p: &Value) -> bool {
|
||||
vectorcraft_engine::cmd::fileio::TEXT_EXTS.contains(&vectorcraft_engine::cmd::fileio::extension(name).as_str())
|
||||
}
|
||||
|
||||
/// Ask how to place the text file `params` (`file.place` params) names.
|
||||
/// Ask how to place the text file `params` (`file.place` params) names, starting from the options
|
||||
/// confirmed last.
|
||||
pub fn open(app: &mut VectorcraftApp, params: &Value) -> Value {
|
||||
let fields = json!({
|
||||
let mut fields = json!({
|
||||
"platform": if cfg!(target_os = "macos") { "mac" } else { "windows" },
|
||||
"characterSet": "unicode",
|
||||
"removeLineReturns": false,
|
||||
@@ -38,10 +40,31 @@ pub fn open(app: &mut VectorcraftApp, params: &Value) -> Value {
|
||||
"spaces": 3,
|
||||
"__params": params,
|
||||
});
|
||||
let last = &app.ui.text_import;
|
||||
for (k, wanted) in [("platform", ["windows", "mac"]), ("characterSet", ["unicode", "ansi"])] {
|
||||
if let Some(v) = last.get(k).and_then(Value::as_str).filter(|v| wanted.contains(v)) {
|
||||
fields[k] = json!(v);
|
||||
}
|
||||
}
|
||||
for k in ["removeLineReturns", "removeParagraphReturns"] {
|
||||
if let Some(v) = last.get(k).and_then(Value::as_bool) {
|
||||
fields[k] = json!(v);
|
||||
}
|
||||
}
|
||||
if let Some(n) = last.get("replaceSpaces").and_then(Value::as_u64).filter(|n| (2..=100).contains(n)) {
|
||||
fields["replaceSpaces"] = json!(true);
|
||||
fields["spaces"] = json!(n);
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
json!({ "dialog": KIND })
|
||||
}
|
||||
|
||||
/// The `text` options a text file is placed with when Show Import Options is off: those confirmed
|
||||
/// last, else `file.place`'s defaults.
|
||||
pub fn last_options(app: &VectorcraftApp) -> Value {
|
||||
if app.ui.text_import.is_object() { app.ui.text_import.clone() } else { json!({}) }
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
widgets::subheader(ui, tl!("Encoding"));
|
||||
form::choice(ui, d, "platform", tl!("Platform:"), (LABEL, 180.0), &[("windows", "Windows"), ("mac", "Mac")]);
|
||||
@@ -87,10 +110,12 @@ fn options(d: &Dialog) -> Value {
|
||||
o
|
||||
}
|
||||
|
||||
/// Places the file with the options; closes first, so an error shows in the status bar.
|
||||
/// Places the file with the options and remembers them for the next text file; closes first, so an
|
||||
/// error shows in the status bar.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut params = d.fields.get("__params").cloned().unwrap_or_else(|| json!({}));
|
||||
params["text"] = options(d);
|
||||
app.ui.text_import = params["text"].clone();
|
||||
app.ui.dialog = None;
|
||||
crate::place::run_for_user(app, ¶ms)
|
||||
}
|
||||
|
||||
@@ -1151,6 +1151,7 @@ font-technology Variable Променливи
|
||||
Rotate (Classic)… Ротация (класическа)…
|
||||
Surface Повърхност
|
||||
Wireframe Телена рамка
|
||||
Render Settings Настройки за рендериране
|
||||
No Shading Без засенчване
|
||||
Plastic Пластмаса
|
||||
Highlight Intensity Интензивност на отблясъка
|
||||
@@ -3520,6 +3521,8 @@ digits Malayalam Малаялам
|
||||
Swap Fill and Stroke (Shift+X) Размяна на запълването и щриха (Shift+X)
|
||||
Swap start and end arrowheads Размяна на началната и крайната стрелка
|
||||
Swap the selected object for the file, keeping its place and transform Замяна на избрания обект с файла, като се запазват мястото и трансформациите му
|
||||
Show Import Options Показване на опциите за импортиране
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Питане за опциите за импортиране на всеки PDF, DXF или текстов файл преди поставянето му; изключено: използват се последно избраните опции
|
||||
Stylistic Alternates Стилистични алтернативи
|
||||
Set {n} Набор {n}
|
||||
Swash Декоративен елемент
|
||||
@@ -6150,6 +6153,8 @@ axis Y Axis Y ос
|
||||
Create New Set Създаване на нов набор
|
||||
Stop Global Edit Край на глобалното редактиране
|
||||
Global Edit Options Опции за глобално редактиране
|
||||
Select Similar Objects Избор на подобни обекти
|
||||
Select Similar Options Опции за избор на подобни
|
||||
Leave Out of Global Edit Изключване от глобалното редактиране
|
||||
@msg Global Edit works on paths, compound paths, symbol instances and groups of them Глобалното редактиране работи с пътища, съставни пътища, екземпляри на символи и групи от тях
|
||||
Match Съвпадение
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -513,6 +513,7 @@
|
||||
Rotate (Classic)… Rotiraj (klasično)…
|
||||
Surface Površina
|
||||
Wireframe Žičani model
|
||||
Render Settings Postavke renderovanja
|
||||
No Shading Bez sjenčenja
|
||||
Plastic Plastika
|
||||
Highlight Intensity Intenzitet odsjaja
|
||||
@@ -2757,6 +2758,8 @@ digits Malayalam Malajalam
|
||||
Keep a link to an image file instead of only embedding it Zadrži vezu na slikovnu datoteku umjesto samo ugrađivanja
|
||||
Place onto a new locked, dimmed template layer below the current layer Postavi na novi zaključani, zatamnjeni sloj predloška ispod trenutnog sloja
|
||||
Swap the selected object for the file, keeping its place and transform Zamijeni odabrani objekat datotekom, zadržavajući njegovo mjesto i transformaciju
|
||||
Show Import Options Prikaži opcije uvoza
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Pitaj za opcije uvoza svake PDF, DXF ili tekstualne datoteke prije postavljanja; isključeno: koriste se posljednje odabrane opcije
|
||||
On: one locked object showing the document, updated when it changes. Uključeno: jedan zaključani objekat koji prikazuje dokument, ažuriran kad se on promijeni.
|
||||
Off: an editable copy of its art, embedded in this document. Isključeno: kopija njegovog crteža koja se može uređivati, ugrađena u ovaj dokument.
|
||||
Images: link to the file instead of only embedding it. Slike: poveži s datotekom umjesto samo ugrađivanja.
|
||||
@@ -6192,6 +6195,8 @@ axis Y Axis Y osa
|
||||
Create New Set Napravi novi skup
|
||||
Stop Global Edit Zaustavi globalno uređivanje
|
||||
Global Edit Options Opcije globalnog uređivanja
|
||||
Select Similar Objects Odaberi slične objekte
|
||||
Select Similar Options Opcije odabira sličnih
|
||||
Leave Out of Global Edit Isključi iz globalnog uređivanja
|
||||
Match Podudaranje
|
||||
On Artboards Na radnim pločama
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1626,6 +1626,7 @@ antiAlias Sharp Nítid
|
||||
Rotate (Classic)… Gira (clàssic)…
|
||||
Surface Superfície
|
||||
Wireframe Filferro
|
||||
Render Settings Configuració de renderització
|
||||
No Shading Sense ombra
|
||||
Plastic Plàstic
|
||||
Highlight Intensity Intensitat de la brillantor
|
||||
@@ -1854,6 +1855,8 @@ digits Malayalam Malaiàlam
|
||||
Global Adjust Ajust global
|
||||
Global Brush Dimensions Dimensions globals del pinzell
|
||||
Global Edit Options Opcions globals d’edició
|
||||
Select Similar Objects Selecciona objectes similars
|
||||
Select Similar Options Opcions de selecció de similars
|
||||
Leave Out of Global Edit Exclou de l’edició global
|
||||
Global Process Color Color de procés global
|
||||
Global Process Color, CMYK Color de procés global, CMYK
|
||||
@@ -4602,6 +4605,8 @@ digits Malayalam Malaiàlam
|
||||
Swap Fill and Stroke (Shift+X) Intercanvia el farciment i el traç (Maj+X)
|
||||
Swap start and end arrowheads Intercanvia les puntes de fletxa inicial i final
|
||||
Swap the selected object for the file, keeping its place and transform Substitueix l’objecte seleccionat pel fitxer, mantenint-ne la posició i la transformació
|
||||
Show Import Options Mostra les opcions d’importació
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Demana les opcions d’importació de cada fitxer PDF, DXF o de text abans de col·locar-lo; desactivat: s’usen les últimes opcions triades
|
||||
Stylistic Alternates Alternatives estilístiques
|
||||
Swash Volada
|
||||
Swatch Conflict Conflicte de mostres
|
||||
@@ -4684,6 +4689,48 @@ digits Malayalam Malaiàlam
|
||||
Composite Fonts Tipus de lletra compostos
|
||||
Export Composite Fonts Exporta tipus de lletra compostos
|
||||
Import Composite Fonts Importa tipus de lletra compostos
|
||||
@msg each font must be {name, parts} cada tipus de lletra ha de ser {name, parts}
|
||||
@msg unknown key `{k}`; a font takes name and parts clau desconeguda `{k}`; un tipus de lletra admet name i parts
|
||||
@msg each font needs a name cada tipus de lletra necessita un nom
|
||||
@msg `{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters) `{raw}` no pot anomenar un tipus de lletra compost (d’1 a {COMPOSITE_NAME_MAX} caràcters, sense caràcters de control)
|
||||
@msg `{name}`: parts must be an object `{name}`: parts ha de ser un objecte
|
||||
@msg `{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals `{name}`: classe desconeguda `{k}`; les classes són kanji, kana, punctuation, symbols, roman i numerals
|
||||
@msg `{name}`: {_1} must be an object `{name}`: {_1} ha de ser un objecte
|
||||
@msg `{name}`: unknown key `{k}` in {_1} `{name}`: clau desconeguda `{k}` a {_1}
|
||||
@msg `{name}`: give the kanji part `{name}`: indiqueu la part kanji
|
||||
@msg a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts un document conté com a màxim {COMPOSITE_FONTS_MAX} tipus de lletra compostos
|
||||
@msg two composite fonts are named `{_1}` dos tipus de lletra compostos es diuen `{_1}`
|
||||
@msg `{_1}` is the name of an installed font `{_1}` és el nom d’un tipus de lletra instal·lat
|
||||
@msg `{_1}`: {_2} is set in `{font}`, a composite font `{_1}`: {_2} usa `{font}`, un tipus de lletra compost
|
||||
@msg give fonts: [{name, parts}…] indiqueu fonts: [{name, parts}…]
|
||||
@msg path is empty path és buit
|
||||
@msg fonts must be a list of composite fonts fonts ha de ser una llista de tipus de lletra compostos
|
||||
@msg names must be strings names ha de contenir cadenes
|
||||
@msg no composite font named `{n}` cap tipus de lletra compost anomenat `{n}`
|
||||
@msg names must be a list of names names ha de ser una llista de noms
|
||||
@msg no composite fonts to export cap tipus de lletra compost per exportar
|
||||
@msg not a composite fonts file: {e} no és un fitxer de tipus de lletra compostos: {e}
|
||||
@msg not a composite fonts file: no compositeFonts list no és un fitxer de tipus de lletra compostos: no hi ha cap llista compositeFonts
|
||||
@msg the file holds more than {COMPOSITE_FONTS_MAX} composite fonts el fitxer conté més de {COMPOSITE_FONTS_MAX} tipus de lletra compostos
|
||||
@msg the file is larger than 4 MB el fitxer ocupa més de 4 MB
|
||||
@msg dataBase64 is not base64 dataBase64 no és base64
|
||||
@msg give one of path, data or dataBase64 indiqueu path, data o dataBase64
|
||||
@msg the file holds no composite fonts el fitxer no conté cap tipus de lletra compost
|
||||
@msg the file holds two composite fonts named `{_1}` el fitxer conté dos tipus de lletra compostos anomenats `{_1}`
|
||||
@msg `{_1}` can't name a composite font `{_1}` no pot anomenar un tipus de lletra compost
|
||||
@msg {k}: give a font {k}: indiqueu un tipus de lletra
|
||||
@msg `{name}`: {k}: give a font `{name}`: {k}: indiqueu un tipus de lletra
|
||||
@msg {k}: size must be {_1} to {_2}% {k}: size ha d’estar entre {_1} i {_2} %
|
||||
@msg `{name}`: {k}: size must be {_1} to {_2}% `{name}`: {k}: size ha d’estar entre {_1} i {_2} %
|
||||
@msg {k}: baseline must be {_1} to {_2}% {k}: baseline ha d’estar entre {_1} i {_2} %
|
||||
@msg `{name}`: {k}: baseline must be {_1} to {_2}% `{name}`: {k}: baseline ha d’estar entre {_1} i {_2} %
|
||||
@msg {k}: scale must be {_1} to {_2}% {k}: l’escala ha d’estar entre {_1} i {_2} %
|
||||
@msg `{name}`: {k}: scale must be {_1} to {_2}% `{name}`: {k}: l’escala ha d’estar entre {_1} i {_2} %
|
||||
@msg {k}: only kana, roman and numerals can be scaled {k}: només kana, roman i numerals es poden escalar
|
||||
@msg `{name}`: {k}: only kana, roman and numerals can be scaled `{name}`: {k}: només kana, roman i numerals es poden escalar
|
||||
@msg {k}: only kana can be scaled at the center {k}: només kana es pot escalar des del centre
|
||||
@msg `{name}`: {k}: only kana can be scaled at the center `{name}`: {k}: només kana es pot escalar des del centre
|
||||
@msg no composite font cap tipus de lletra compost
|
||||
Delete Font Suprimeix el tipus de lletra
|
||||
Kanji Kanji
|
||||
Kana Kana
|
||||
@@ -6213,6 +6260,7 @@ digits Malayalam Malaiàlam
|
||||
@msg a 3D effect on a group's fill or stroke draws nothing to export un efecte 3D al farciment o al traç d’un grup no dibuixa res per exportar
|
||||
@msg The 3D object is too complex to export L’objecte 3D és massa complex per exportar-lo
|
||||
@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) l’objecte no té cap efecte 3D (Extrudeix i bisella, Revoluciona, Infla o Gira)
|
||||
@msg `render` must be an object holding only `mode`, set to shaded or wireframe `render` ha de ser un objecte que només contingui `mode`, amb el valor shaded o wireframe
|
||||
@msg its 3D effect builds no solid el seu efecte 3D no construeix cap sòlid
|
||||
@msg bevel must be none or one of {_1} bevel ha de ser none o un de {_1}
|
||||
@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} no pot construir sòlids a partir d’aquesta il·lustració: les seves instàncies de símbol, fusions i grups de retall contenen massa objectes
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -526,6 +526,7 @@
|
||||
Rotate (Classic)… Otočit (klasické)…
|
||||
Surface Povrch
|
||||
Wireframe Drátový model
|
||||
Render Settings Nastavení vykreslování
|
||||
No Shading Bez stínování
|
||||
Plastic Plast
|
||||
Highlight Intensity Intenzita odlesku
|
||||
@@ -1664,6 +1665,48 @@ digits Malayalam Malajálamské
|
||||
Composite Fonts Složená písma
|
||||
Export Composite Fonts Exportovat složená písma
|
||||
Import Composite Fonts Importovat složená písma
|
||||
@msg each font must be {name, parts} každé písmo musí být {name, parts}
|
||||
@msg unknown key `{k}`; a font takes name and parts neznámý klíč `{k}`; písmo přijímá name a parts
|
||||
@msg each font needs a name každé písmo potřebuje název
|
||||
@msg `{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters) `{raw}` nemůže být názvem složeného písma (1 až {COMPOSITE_NAME_MAX} znaků, bez řídicích znaků)
|
||||
@msg `{name}`: parts must be an object `{name}`: parts musí být objekt
|
||||
@msg `{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals `{name}`: neznámá třída `{k}`; třídy jsou kanji, kana, punctuation, symbols, roman a numerals
|
||||
@msg `{name}`: {_1} must be an object `{name}`: {_1} musí být objekt
|
||||
@msg `{name}`: unknown key `{k}` in {_1} `{name}`: neznámý klíč `{k}` v {_1}
|
||||
@msg `{name}`: give the kanji part `{name}`: zadejte část kanji
|
||||
@msg a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts dokument obsahuje nejvýše {COMPOSITE_FONTS_MAX} složených písem
|
||||
@msg two composite fonts are named `{_1}` dvě složená písma se jmenují `{_1}`
|
||||
@msg `{_1}` is the name of an installed font `{_1}` je název nainstalovaného písma
|
||||
@msg `{_1}`: {_2} is set in `{font}`, a composite font `{_1}`: {_2} používá `{font}`, což je složené písmo
|
||||
@msg give fonts: [{name, parts}…] zadejte fonts: [{name, parts}…]
|
||||
@msg path is empty path je prázdné
|
||||
@msg fonts must be a list of composite fonts fonts musí být seznam složených písem
|
||||
@msg names must be strings names musí být řetězce
|
||||
@msg no composite font named `{n}` žádné složené písmo s názvem `{n}`
|
||||
@msg names must be a list of names names musí být seznam názvů
|
||||
@msg no composite fonts to export žádná složená písma k exportu
|
||||
@msg not a composite fonts file: {e} nejde o soubor složených písem: {e}
|
||||
@msg not a composite fonts file: no compositeFonts list nejde o soubor složených písem: chybí seznam compositeFonts
|
||||
@msg the file holds more than {COMPOSITE_FONTS_MAX} composite fonts soubor obsahuje více než {COMPOSITE_FONTS_MAX} složených písem
|
||||
@msg the file is larger than 4 MB soubor je větší než 4 MB
|
||||
@msg dataBase64 is not base64 dataBase64 není base64
|
||||
@msg give one of path, data or dataBase64 zadejte path, data nebo dataBase64
|
||||
@msg the file holds no composite fonts soubor neobsahuje žádná složená písma
|
||||
@msg the file holds two composite fonts named `{_1}` soubor obsahuje dvě složená písma s názvem `{_1}`
|
||||
@msg `{_1}` can't name a composite font `{_1}` nemůže být názvem složeného písma
|
||||
@msg {k}: give a font {k}: zadejte písmo
|
||||
@msg `{name}`: {k}: give a font `{name}`: {k}: zadejte písmo
|
||||
@msg {k}: size must be {_1} to {_2}% {k}: size musí být {_1} až {_2} %
|
||||
@msg `{name}`: {k}: size must be {_1} to {_2}% `{name}`: {k}: size musí být {_1} až {_2} %
|
||||
@msg {k}: baseline must be {_1} to {_2}% {k}: baseline musí být {_1} až {_2} %
|
||||
@msg `{name}`: {k}: baseline must be {_1} to {_2}% `{name}`: {k}: baseline musí být {_1} až {_2} %
|
||||
@msg {k}: scale must be {_1} to {_2}% {k}: měřítko musí být {_1} až {_2} %
|
||||
@msg `{name}`: {k}: scale must be {_1} to {_2}% `{name}`: {k}: měřítko musí být {_1} až {_2} %
|
||||
@msg {k}: only kana, roman and numerals can be scaled {k}: měnit měřítko lze jen u kana, roman a numerals
|
||||
@msg `{name}`: {k}: only kana, roman and numerals can be scaled `{name}`: {k}: měnit měřítko lze jen u kana, roman a numerals
|
||||
@msg {k}: only kana can be scaled at the center {k}: měřítko od středu lze měnit jen u kana
|
||||
@msg `{name}`: {k}: only kana can be scaled at the center `{name}`: {k}: měřítko od středu lze měnit jen u kana
|
||||
@msg no composite font žádné složené písmo
|
||||
Delete Font Odstranit písmo
|
||||
Kanji Kandži
|
||||
Kana Kana
|
||||
@@ -2792,6 +2835,8 @@ digits Malayalam Malajálamské
|
||||
Keep a link to an image file instead of only embedding it Ponechat propojení na soubor obrázku, ne jen ho vložit
|
||||
Place onto a new locked, dimmed template layer below the current layer Umístit do nové zamčené, ztlumené vrstvy šablony pod aktuální vrstvou
|
||||
Swap the selected object for the file, keeping its place and transform Nahradit vybraný objekt souborem se zachováním jeho polohy a transformace
|
||||
Show Import Options Zobrazit možnosti importu
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Před umístěním se zeptat na možnosti importu každého souboru PDF, DXF nebo textového souboru; vypnuto: použijí se naposledy zvolené možnosti
|
||||
On: one locked object showing the document, updated when it changes. Zapnuto: jeden zamčený objekt zobrazující dokument, aktualizovaný při jeho změně.
|
||||
Off: an editable copy of its art, embedded in this document. Vypnuto: upravitelná kopie jeho kresby vložená do tohoto dokumentu.
|
||||
Images: link to the file instead of only embedding it. Obrázky: propojit se souborem místo pouhého vložení.
|
||||
@@ -6024,6 +6069,7 @@ mark-style Japanese Japonské
|
||||
@msg a 3D effect on a group's fill or stroke draws nothing to export 3D efekt na výplni nebo tahu skupiny nekreslí nic k exportu
|
||||
@msg The 3D object is too complex to export 3D objekt je pro export příliš složitý
|
||||
@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) objekt nemá žádný 3D efekt (Vysunutí a zkosení, Rotační těleso, Nafouknutí ani Otočit)
|
||||
@msg `render` must be an object holding only `mode`, set to shaded or wireframe `render` musí být objekt obsahující pouze `mode` s hodnotou shaded nebo wireframe
|
||||
@msg its 3D effect builds no solid jeho 3D efekt nevytváří žádné těleso
|
||||
@msg bevel must be none or one of {_1} bevel musí být none nebo jedno z {_1}
|
||||
@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} nemůže z této kresby vytvořit tělesa: její instance symbolů, prolnutí a ořezové skupiny obsahují příliš mnoho objektů
|
||||
@@ -6698,6 +6744,8 @@ axis Y Axis osa Y
|
||||
Create New Set Vytvořit novou sadu
|
||||
Stop Global Edit Ukončit globální úpravy
|
||||
Global Edit Options Možnosti globálních úprav
|
||||
Select Similar Objects Vybrat podobné objekty
|
||||
Select Similar Options Možnosti výběru podobných
|
||||
Leave Out of Global Edit Vyřadit z globálních úprav
|
||||
Match Shoda
|
||||
On Artboards Na pracovních plochách
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -291,6 +291,7 @@
|
||||
@msg a 3D effect on a group's fill or stroke draws nothing to export en 3D-effekt på en gruppes udfyldning eller streg tegner intet at eksportere
|
||||
@msg The 3D object is too complex to export 3D-objektet er for komplekst til at blive eksporteret
|
||||
@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) objektet har ingen 3D-effekt (Ekstrudér og facet, Omdrej, Pust op eller Rotér)
|
||||
@msg `render` must be an object holding only `mode`, set to shaded or wireframe `render` skal være et objekt, der kun indeholder `mode` med værdien shaded eller wireframe
|
||||
@msg its 3D effect builds no solid dens 3D-effekt danner intet legeme
|
||||
@msg bevel must be none or one of {_1} bevel skal være none eller en af {_1}
|
||||
@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} kan ikke danne legemer af denne grafik: dens symbolforekomster, blandinger og beskæringsgrupper indeholder for mange objekter
|
||||
@@ -1089,6 +1090,7 @@
|
||||
Rotate (Classic)… Rotér (klassisk)…
|
||||
Surface Overflade
|
||||
Wireframe Trådmodel
|
||||
Render Settings Renderingsindstillinger
|
||||
No Shading Ingen skyggelægning
|
||||
Plastic Plastik
|
||||
Highlight Intensity Højlysintensitet
|
||||
@@ -2179,6 +2181,48 @@ digits Malayalam Malayalam
|
||||
Composite Fonts Sammensatte skrifttyper
|
||||
Export Composite Fonts Eksportér sammensatte skrifttyper
|
||||
Import Composite Fonts Importér sammensatte skrifttyper
|
||||
@msg each font must be {name, parts} hver skrifttype skal være {name, parts}
|
||||
@msg unknown key `{k}`; a font takes name and parts ukendt nøgle `{k}`; en skrifttype tager name og parts
|
||||
@msg each font needs a name hver skrifttype skal have et navn
|
||||
@msg `{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters) `{raw}` kan ikke være navnet på en sammensat skrifttype (1 til {COMPOSITE_NAME_MAX} tegn, ingen kontroltegn)
|
||||
@msg `{name}`: parts must be an object `{name}`: parts skal være et objekt
|
||||
@msg `{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals `{name}`: ukendt klasse `{k}`; klasserne er kanji, kana, punctuation, symbols, roman og numerals
|
||||
@msg `{name}`: {_1} must be an object `{name}`: {_1} skal være et objekt
|
||||
@msg `{name}`: unknown key `{k}` in {_1} `{name}`: ukendt nøgle `{k}` i {_1}
|
||||
@msg `{name}`: give the kanji part `{name}`: angiv delen kanji
|
||||
@msg a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts et dokument har højst {COMPOSITE_FONTS_MAX} sammensatte skrifttyper
|
||||
@msg two composite fonts are named `{_1}` to sammensatte skrifttyper hedder `{_1}`
|
||||
@msg `{_1}` is the name of an installed font `{_1}` er navnet på en installeret skrifttype
|
||||
@msg `{_1}`: {_2} is set in `{font}`, a composite font `{_1}`: {_2} er sat med `{font}`, en sammensat skrifttype
|
||||
@msg give fonts: [{name, parts}…] angiv fonts: [{name, parts}…]
|
||||
@msg path is empty path er tom
|
||||
@msg fonts must be a list of composite fonts fonts skal være en liste med sammensatte skrifttyper
|
||||
@msg names must be strings names skal være strenge
|
||||
@msg no composite font named `{n}` ingen sammensat skrifttype hedder `{n}`
|
||||
@msg names must be a list of names names skal være en liste med navne
|
||||
@msg no composite fonts to export ingen sammensatte skrifttyper at eksportere
|
||||
@msg not a composite fonts file: {e} ikke en fil med sammensatte skrifttyper: {e}
|
||||
@msg not a composite fonts file: no compositeFonts list ikke en fil med sammensatte skrifttyper: ingen liste compositeFonts
|
||||
@msg the file holds more than {COMPOSITE_FONTS_MAX} composite fonts filen indeholder mere end {COMPOSITE_FONTS_MAX} sammensatte skrifttyper
|
||||
@msg the file is larger than 4 MB filen er større end 4 MB
|
||||
@msg dataBase64 is not base64 dataBase64 er ikke base64
|
||||
@msg give one of path, data or dataBase64 angiv én af path, data eller dataBase64
|
||||
@msg the file holds no composite fonts filen indeholder ingen sammensatte skrifttyper
|
||||
@msg the file holds two composite fonts named `{_1}` filen indeholder to sammensatte skrifttyper, der hedder `{_1}`
|
||||
@msg `{_1}` can't name a composite font `{_1}` kan ikke være navnet på en sammensat skrifttype
|
||||
@msg {k}: give a font {k}: angiv en skrifttype
|
||||
@msg `{name}`: {k}: give a font `{name}`: {k}: angiv en skrifttype
|
||||
@msg {k}: size must be {_1} to {_2}% {k}: size skal være {_1} til {_2}%
|
||||
@msg `{name}`: {k}: size must be {_1} to {_2}% `{name}`: {k}: size skal være {_1} til {_2}%
|
||||
@msg {k}: baseline must be {_1} to {_2}% {k}: baseline skal være {_1} til {_2}%
|
||||
@msg `{name}`: {k}: baseline must be {_1} to {_2}% `{name}`: {k}: baseline skal være {_1} til {_2}%
|
||||
@msg {k}: scale must be {_1} to {_2}% {k}: skaleringen skal være {_1} til {_2}%
|
||||
@msg `{name}`: {k}: scale must be {_1} to {_2}% `{name}`: {k}: skaleringen skal være {_1} til {_2}%
|
||||
@msg {k}: only kana, roman and numerals can be scaled {k}: kun kana, roman og numerals kan skaleres
|
||||
@msg `{name}`: {k}: only kana, roman and numerals can be scaled `{name}`: {k}: kun kana, roman og numerals kan skaleres
|
||||
@msg {k}: only kana can be scaled at the center {k}: kun kana kan skaleres fra midten
|
||||
@msg `{name}`: {k}: only kana can be scaled at the center `{name}`: {k}: kun kana kan skaleres fra midten
|
||||
@msg no composite font ingen sammensat skrifttype
|
||||
Delete Font Slet skrifttype
|
||||
Kanji Kanji
|
||||
Kana Kana
|
||||
@@ -3183,6 +3227,8 @@ digits Malayalam Malayalam
|
||||
Keep a link to an image file instead of only embedding it Behold et link til en billedfil i stedet for kun at indlejre den
|
||||
Place onto a new locked, dimmed template layer below the current layer Placér på et nyt låst, nedtonet skabelonlag under det aktuelle lag
|
||||
Swap the selected object for the file, keeping its place and transform Udskift det markerede objekt med filen, mens placering og transformering bevares
|
||||
Show Import Options Vis importindstillinger
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Spørg om importindstillinger for hver PDF-, DXF- eller tekstfil, før den placeres; fra: brug de senest valgte indstillinger
|
||||
On: one locked object showing the document, updated when it changes. Til: ét låst objekt, der viser dokumentet, og som opdateres, når det ændres.
|
||||
Off: an editable copy of its art, embedded in this document. Fra: en redigerbar kopi af dens grafik, indlejret i dette dokument.
|
||||
Images: link to the file instead of only embedding it. Billeder: link til filen i stedet for kun at indlejre den.
|
||||
@@ -6776,6 +6822,8 @@ axis Y Axis Y-akse
|
||||
Create New Set Opret nyt sæt
|
||||
Stop Global Edit Stop global redigering
|
||||
Global Edit Options Indstillinger for global redigering
|
||||
Select Similar Objects Vælg lignende objekter
|
||||
Select Similar Options Indstillinger for valg af lignende
|
||||
Leave Out of Global Edit Udelad fra global redigering
|
||||
@msg Global Edit works on paths, compound paths, symbol instances and groups of them Global redigering virker på stier, sammensatte stier, symbolforekomster og grupper af dem
|
||||
Match Match
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1164,6 +1164,7 @@ font-technology Variable Variabel
|
||||
Rotate (Classic)… Drehen (klassisch)…
|
||||
Surface Oberfläche
|
||||
Wireframe Drahtmodell
|
||||
Render Settings Rendereinstellungen
|
||||
No Shading Keine Schattierung
|
||||
Plastic Kunststoff
|
||||
Highlight Intensity Glanzpunktintensität
|
||||
@@ -3583,6 +3584,8 @@ effect Points Punkte
|
||||
Swap Fill and Stroke (Shift+X) Fläche und Kontur vertauschen (Shift+X)
|
||||
Swap start and end arrowheads Pfeilspitzen an Anfang und Ende vertauschen
|
||||
Swap the selected object for the file, keeping its place and transform Das ausgewählte Objekt durch die Datei ersetzen; Position und Transformation bleiben erhalten
|
||||
Show Import Options Importoptionen anzeigen
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Vor dem Platzieren nach den Importoptionen jeder PDF-, DXF- oder Textdatei fragen; aus: die zuletzt gewählten Optionen verwenden
|
||||
Stylistic Alternates Stilistische Varianten
|
||||
Swash Schwungschrift
|
||||
Swatch Conflict Farbfeldkonflikt
|
||||
@@ -3653,6 +3656,48 @@ effect Points Punkte
|
||||
Composite Fonts Zusammengesetzte Schriften
|
||||
Export Composite Fonts Zusammengesetzte Schriften exportieren
|
||||
Import Composite Fonts Zusammengesetzte Schriften importieren
|
||||
@msg each font must be {name, parts} jede Schrift muss {name, parts} sein
|
||||
@msg unknown key `{k}`; a font takes name and parts unbekannter Schlüssel `{k}`; eine Schrift nimmt name und parts
|
||||
@msg each font needs a name jede Schrift braucht einen Namen
|
||||
@msg `{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters) `{raw}` kann keine zusammengesetzte Schrift benennen (1 bis {COMPOSITE_NAME_MAX} Zeichen, keine Steuerzeichen)
|
||||
@msg `{name}`: parts must be an object `{name}`: parts muss ein Objekt sein
|
||||
@msg `{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals `{name}`: unbekannte Klasse `{k}`; die Klassen sind kanji, kana, punctuation, symbols, roman und numerals
|
||||
@msg `{name}`: {_1} must be an object `{name}`: {_1} muss ein Objekt sein
|
||||
@msg `{name}`: unknown key `{k}` in {_1} `{name}`: unbekannter Schlüssel `{k}` in {_1}
|
||||
@msg `{name}`: give the kanji part `{name}`: geben Sie den Teil kanji an
|
||||
@msg a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts ein Dokument enthält höchstens {COMPOSITE_FONTS_MAX} zusammengesetzte Schriften
|
||||
@msg two composite fonts are named `{_1}` zwei zusammengesetzte Schriften heißen `{_1}`
|
||||
@msg `{_1}` is the name of an installed font `{_1}` ist der Name einer installierten Schrift
|
||||
@msg `{_1}`: {_2} is set in `{font}`, a composite font `{_1}`: {_2} ist in `{font}` gesetzt, einer zusammengesetzten Schrift
|
||||
@msg give fonts: [{name, parts}…] geben Sie fonts an: [{name, parts}…]
|
||||
@msg path is empty path ist leer
|
||||
@msg fonts must be a list of composite fonts fonts muss eine Liste zusammengesetzter Schriften sein
|
||||
@msg names must be strings names müssen Zeichenketten sein
|
||||
@msg no composite font named `{n}` keine zusammengesetzte Schrift namens `{n}`
|
||||
@msg names must be a list of names names muss eine Liste von Namen sein
|
||||
@msg no composite fonts to export keine zusammengesetzten Schriften zum Exportieren
|
||||
@msg not a composite fonts file: {e} keine Datei mit zusammengesetzten Schriften: {e}
|
||||
@msg not a composite fonts file: no compositeFonts list keine Datei mit zusammengesetzten Schriften: keine Liste compositeFonts
|
||||
@msg the file holds more than {COMPOSITE_FONTS_MAX} composite fonts die Datei enthält mehr als {COMPOSITE_FONTS_MAX} zusammengesetzte Schriften
|
||||
@msg the file is larger than 4 MB die Datei ist größer als 4 MB
|
||||
@msg dataBase64 is not base64 dataBase64 ist kein Base64
|
||||
@msg give one of path, data or dataBase64 geben Sie path, data oder dataBase64 an
|
||||
@msg the file holds no composite fonts die Datei enthält keine zusammengesetzten Schriften
|
||||
@msg the file holds two composite fonts named `{_1}` die Datei enthält zwei zusammengesetzte Schriften namens `{_1}`
|
||||
@msg `{_1}` can't name a composite font `{_1}` kann keine zusammengesetzte Schrift benennen
|
||||
@msg {k}: give a font {k}: geben Sie eine Schrift an
|
||||
@msg `{name}`: {k}: give a font `{name}`: {k}: geben Sie eine Schrift an
|
||||
@msg {k}: size must be {_1} to {_2}% {k}: size muss {_1} bis {_2} % sein
|
||||
@msg `{name}`: {k}: size must be {_1} to {_2}% `{name}`: {k}: size muss {_1} bis {_2} % sein
|
||||
@msg {k}: baseline must be {_1} to {_2}% {k}: baseline muss {_1} bis {_2} % sein
|
||||
@msg `{name}`: {k}: baseline must be {_1} to {_2}% `{name}`: {k}: baseline muss {_1} bis {_2} % sein
|
||||
@msg {k}: scale must be {_1} to {_2}% {k}: die Skalierung muss {_1} bis {_2} % sein
|
||||
@msg `{name}`: {k}: scale must be {_1} to {_2}% `{name}`: {k}: die Skalierung muss {_1} bis {_2} % sein
|
||||
@msg {k}: only kana, roman and numerals can be scaled {k}: nur kana, roman und numerals lassen sich skalieren
|
||||
@msg `{name}`: {k}: only kana, roman and numerals can be scaled `{name}`: {k}: nur kana, roman und numerals lassen sich skalieren
|
||||
@msg {k}: only kana can be scaled at the center {k}: nur kana lässt sich am Mittelpunkt skalieren
|
||||
@msg `{name}`: {k}: only kana can be scaled at the center `{name}`: {k}: nur kana lässt sich am Mittelpunkt skalieren
|
||||
@msg no composite font keine zusammengesetzte Schrift
|
||||
Delete Font Schrift löschen
|
||||
Kanji Kanji
|
||||
Kana Kana
|
||||
@@ -5596,6 +5641,7 @@ lighting Light Licht
|
||||
@msg a 3D effect on a group's fill or stroke draws nothing to export ein 3D-Effekt auf der Fläche oder Kontur einer Gruppe zeichnet nichts, das exportiert werden kann
|
||||
@msg The 3D object is too complex to export Das 3D-Objekt ist zu komplex für den Export
|
||||
@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) das Objekt hat keinen 3D-Effekt (Extrudieren und abgeflachte Kante, Kreiseln, Aufblasen oder Drehen)
|
||||
@msg `render` must be an object holding only `mode`, set to shaded or wireframe `render` muss ein Objekt sein, das nur `mode` mit dem Wert shaded oder wireframe enthält
|
||||
@msg its 3D effect builds no solid sein 3D-Effekt erzeugt keinen Körper
|
||||
@msg bevel must be none or one of {_1} bevel muss none oder einer der folgenden Werte sein: {_1}
|
||||
@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} kann aus diesem Bildmaterial keine Körper erstellen: Seine Symbolinstanzen, Angleichungen und Schnittgruppen enthalten zu viele Objekte
|
||||
@@ -6288,6 +6334,8 @@ axis Y Axis Y-Achse
|
||||
Create New Set Neuen Satz erstellen
|
||||
Stop Global Edit Globale Bearbeitung beenden
|
||||
Global Edit Options Optionen für globale Bearbeitung
|
||||
Select Similar Objects Ähnliche Objekte auswählen
|
||||
Select Similar Options Optionen für ähnliche Objekte
|
||||
Leave Out of Global Edit Von globaler Bearbeitung ausschließen
|
||||
Match Übereinstimmung
|
||||
On Artboards Auf Zeichenflächen
|
||||
@@ -8994,6 +9042,7 @@ breakText Glyphs Glyphen
|
||||
@msg a line can't run from a point to itself eine Linie kann nicht von einem Punkt zu sich selbst verlaufen
|
||||
@msg a line needs at least two points eine Linie braucht mindestens zwei Punkte
|
||||
@msg a linked JavaScript file is outside the read roots eine verknüpfte JavaScript-Datei liegt außerhalb der Lesestammordner
|
||||
@msg a linked image file is outside the read roots eine verknüpfte Bilddatei liegt außerhalb der Lesestammordner
|
||||
@msg a number cannot be read eine Zahl kann nicht gelesen werden
|
||||
@msg a swatch exchange block is too long ein Block der Farbfeld-Austauschdatei ist zu lang
|
||||
@msg a swatch exchange name is too long ein Name in der Farbfeld-Austauschdatei ist zu lang
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -1129,6 +1129,7 @@ font-technology Variable Μεταβλητές
|
||||
Rotate (Classic)… Περιστροφή (κλασικό)…
|
||||
Surface Επιφάνεια
|
||||
Wireframe Συρματόπλεγμα
|
||||
Render Settings Ρυθμίσεις απόδοσης
|
||||
No Shading Χωρίς σκίαση
|
||||
Plastic Πλαστικό
|
||||
Highlight Intensity Ένταση ανταύγειας
|
||||
@@ -4108,6 +4109,8 @@ digits Malayalam Μαλαγιάλαμ
|
||||
Swap Fill and Stroke (Shift+X) Εναλλαγή γεμίσματος και περιγράμματος (Shift+X)
|
||||
Swap start and end arrowheads Εναλλαγή αρχικής και τελικής αιχμής βέλους
|
||||
Swap the selected object for the file, keeping its place and transform Αντικατάσταση επιλεγμένου αντικειμένου με το αρχείο, διατηρώντας θέση και μετασχηματισμό
|
||||
Show Import Options Εμφάνιση επιλογών εισαγωγής
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Ερώτηση για τις επιλογές εισαγωγής κάθε αρχείου PDF, DXF ή κειμένου πριν από την τοποθέτηση· απενεργοποιημένο: χρήση των τελευταίων επιλογών
|
||||
Swatch Conflict Διένεξη δείγματος χρώματος
|
||||
Swatch Conflicts Διενέξεις δειγμάτων χρωμάτων
|
||||
Swatch exchange files keep solid colors only. Τα αρχεία ανταλλαγής δειγμάτων διατηρούν μόνο συμπαγή χρώματα.
|
||||
@@ -4187,6 +4190,48 @@ digits Malayalam Μαλαγιάλαμ
|
||||
Composite Fonts Σύνθετες γραμματοσειρές
|
||||
Export Composite Fonts Εξαγωγή σύνθετων γραμματοσειρών
|
||||
Import Composite Fonts Εισαγωγή σύνθετων γραμματοσειρών
|
||||
@msg each font must be {name, parts} κάθε γραμματοσειρά πρέπει να είναι {name, parts}
|
||||
@msg unknown key `{k}`; a font takes name and parts άγνωστο κλειδί `{k}`· μια γραμματοσειρά δέχεται name και parts
|
||||
@msg each font needs a name κάθε γραμματοσειρά χρειάζεται όνομα
|
||||
@msg `{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters) το `{raw}` δεν μπορεί να είναι όνομα σύνθετης γραμματοσειράς (1 έως {COMPOSITE_NAME_MAX} χαρακτήρες, χωρίς χαρακτήρες ελέγχου)
|
||||
@msg `{name}`: parts must be an object `{name}`: το parts πρέπει να είναι αντικείμενο
|
||||
@msg `{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals `{name}`: άγνωστη κλάση `{k}`· οι κλάσεις είναι kanji, kana, punctuation, symbols, roman και numerals
|
||||
@msg `{name}`: {_1} must be an object `{name}`: το {_1} πρέπει να είναι αντικείμενο
|
||||
@msg `{name}`: unknown key `{k}` in {_1} `{name}`: άγνωστο κλειδί `{k}` στο {_1}
|
||||
@msg `{name}`: give the kanji part `{name}`: δώστε το τμήμα kanji
|
||||
@msg a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts ένα έγγραφο περιέχει το πολύ {COMPOSITE_FONTS_MAX} σύνθετες γραμματοσειρές
|
||||
@msg two composite fonts are named `{_1}` δύο σύνθετες γραμματοσειρές ονομάζονται `{_1}`
|
||||
@msg `{_1}` is the name of an installed font το `{_1}` είναι όνομα εγκατεστημένης γραμματοσειράς
|
||||
@msg `{_1}`: {_2} is set in `{font}`, a composite font `{_1}`: το {_2} χρησιμοποιεί την `{font}`, που είναι σύνθετη γραμματοσειρά
|
||||
@msg give fonts: [{name, parts}…] δώστε fonts: [{name, parts}…]
|
||||
@msg path is empty το path είναι κενό
|
||||
@msg fonts must be a list of composite fonts το fonts πρέπει να είναι λίστα σύνθετων γραμματοσειρών
|
||||
@msg names must be strings τα names πρέπει να είναι συμβολοσειρές
|
||||
@msg no composite font named `{n}` δεν υπάρχει σύνθετη γραμματοσειρά με όνομα `{n}`
|
||||
@msg names must be a list of names το names πρέπει να είναι λίστα ονομάτων
|
||||
@msg no composite fonts to export δεν υπάρχουν σύνθετες γραμματοσειρές για εξαγωγή
|
||||
@msg not a composite fonts file: {e} δεν είναι αρχείο σύνθετων γραμματοσειρών: {e}
|
||||
@msg not a composite fonts file: no compositeFonts list δεν είναι αρχείο σύνθετων γραμματοσειρών: δεν υπάρχει λίστα compositeFonts
|
||||
@msg the file holds more than {COMPOSITE_FONTS_MAX} composite fonts το αρχείο περιέχει περισσότερες από {COMPOSITE_FONTS_MAX} σύνθετες γραμματοσειρές
|
||||
@msg the file is larger than 4 MB το αρχείο είναι μεγαλύτερο από 4 MB
|
||||
@msg dataBase64 is not base64 το dataBase64 δεν είναι base64
|
||||
@msg give one of path, data or dataBase64 δώστε path, data ή dataBase64
|
||||
@msg the file holds no composite fonts το αρχείο δεν περιέχει σύνθετες γραμματοσειρές
|
||||
@msg the file holds two composite fonts named `{_1}` το αρχείο περιέχει δύο σύνθετες γραμματοσειρές με όνομα `{_1}`
|
||||
@msg `{_1}` can't name a composite font το `{_1}` δεν μπορεί να είναι όνομα σύνθετης γραμματοσειράς
|
||||
@msg {k}: give a font {k}: δώστε γραμματοσειρά
|
||||
@msg `{name}`: {k}: give a font `{name}`: {k}: δώστε γραμματοσειρά
|
||||
@msg {k}: size must be {_1} to {_2}% {k}: το size πρέπει να είναι από {_1} έως {_2}%
|
||||
@msg `{name}`: {k}: size must be {_1} to {_2}% `{name}`: {k}: το size πρέπει να είναι από {_1} έως {_2}%
|
||||
@msg {k}: baseline must be {_1} to {_2}% {k}: το baseline πρέπει να είναι από {_1} έως {_2}%
|
||||
@msg `{name}`: {k}: baseline must be {_1} to {_2}% `{name}`: {k}: το baseline πρέπει να είναι από {_1} έως {_2}%
|
||||
@msg {k}: scale must be {_1} to {_2}% {k}: η κλίμακα πρέπει να είναι από {_1} έως {_2}%
|
||||
@msg `{name}`: {k}: scale must be {_1} to {_2}% `{name}`: {k}: η κλίμακα πρέπει να είναι από {_1} έως {_2}%
|
||||
@msg {k}: only kana, roman and numerals can be scaled {k}: μόνο τα kana, roman και numerals αλλάζουν κλίμακα
|
||||
@msg `{name}`: {k}: only kana, roman and numerals can be scaled `{name}`: {k}: μόνο τα kana, roman και numerals αλλάζουν κλίμακα
|
||||
@msg {k}: only kana can be scaled at the center {k}: μόνο το kana αλλάζει κλίμακα από το κέντρο
|
||||
@msg `{name}`: {k}: only kana can be scaled at the center `{name}`: {k}: μόνο το kana αλλάζει κλίμακα από το κέντρο
|
||||
@msg no composite font καμία σύνθετη γραμματοσειρά
|
||||
Delete Font Διαγραφή γραμματοσειράς
|
||||
Kanji Κάντζι
|
||||
Kana Κάνα
|
||||
@@ -5387,6 +5432,7 @@ digits Malayalam Μαλαγιάλαμ
|
||||
@msg a 3D effect on a group's fill or stroke draws nothing to export ένα εφέ 3D στο γέμισμα ή στην πινελιά μιας ομάδας δεν σχεδιάζει τίποτα για εξαγωγή
|
||||
@msg The 3D object is too complex to export Το αντικείμενο 3D είναι πολύ σύνθετο για εξαγωγή
|
||||
@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) το αντικείμενο δεν έχει εφέ 3D (Εξώθηση και λοξότμηση, Περιστροφή γύρω από άξονα, Φούσκωμα ή Περιστροφή)
|
||||
@msg `render` must be an object holding only `mode`, set to shaded or wireframe το `render` πρέπει να είναι αντικείμενο που περιέχει μόνο `mode`, με τιμή shaded ή wireframe
|
||||
@msg its 3D effect builds no solid το εφέ 3D του δεν δημιουργεί στερεό
|
||||
@msg bevel must be none or one of {_1} το bevel πρέπει να είναι none ή ένα από τα {_1}
|
||||
@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}: δεν μπορούν να δημιουργηθούν στερεά από αυτό το σχέδιο· οι παρουσίες συμβόλων, οι αναμείξεις και οι ομάδες αποκοπής του περιέχουν πάρα πολλά αντικείμενα
|
||||
@@ -6654,6 +6700,8 @@ axis Y Axis Άξονας Y
|
||||
Create New Set Δημιουργία νέου συνόλου
|
||||
Stop Global Edit Τερματισμός καθολικής επεξεργασίας
|
||||
Global Edit Options Επιλογές καθολικής επεξεργασίας
|
||||
Select Similar Objects Επιλογή παρόμοιων αντικειμένων
|
||||
Select Similar Options Επιλογές επιλογής παρόμοιων
|
||||
Leave Out of Global Edit Εξαίρεση από την καθολική επεξεργασία
|
||||
Match Αντιστοίχιση
|
||||
On Artboards Σε πίνακες σχεδίασης
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -1185,6 +1185,7 @@ font-technology Variable Variables
|
||||
Rotate (Classic)… Rotar (clásico)…
|
||||
Surface Superficie
|
||||
Wireframe Malla
|
||||
Render Settings Ajustes de renderizado
|
||||
No Shading Sin sombreado
|
||||
Plastic Plástico
|
||||
Highlight Intensity Intensidad del brillo
|
||||
@@ -3612,6 +3613,8 @@ digits Malayalam Malayálam
|
||||
Swap Fill and Stroke (Shift+X) Intercambiar relleno y trazo (Shift+X)
|
||||
Swap start and end arrowheads Intercambiar puntas de flecha inicial y final
|
||||
Swap the selected object for the file, keeping its place and transform Sustituir el objeto seleccionado por el archivo, conservando su posición y transformación
|
||||
Show Import Options Mostrar opciones de importación
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Preguntar las opciones de importación de cada archivo PDF, DXF o de texto antes de colocarlo; desactivado: usar las últimas opciones elegidas
|
||||
Stylistic Alternates Alternativas estilísticas
|
||||
Swash Decorativa
|
||||
Swatch Conflict Conflicto de muestras
|
||||
@@ -3701,6 +3704,48 @@ digits Malayalam Malayálam
|
||||
Composite Fonts Fuentes compuestas
|
||||
Export Composite Fonts Exportar fuentes compuestas
|
||||
Import Composite Fonts Importar fuentes compuestas
|
||||
@msg each font must be {name, parts} cada fuente debe ser {name, parts}
|
||||
@msg unknown key `{k}`; a font takes name and parts clave desconocida `{k}`; una fuente admite name y parts
|
||||
@msg each font needs a name cada fuente necesita un nombre
|
||||
@msg `{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters) `{raw}` no puede nombrar una fuente compuesta (de 1 a {COMPOSITE_NAME_MAX} caracteres, sin caracteres de control)
|
||||
@msg `{name}`: parts must be an object `{name}`: parts debe ser un objeto
|
||||
@msg `{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals `{name}`: clase desconocida `{k}`; las clases son kanji, kana, punctuation, symbols, roman y numerals
|
||||
@msg `{name}`: {_1} must be an object `{name}`: {_1} debe ser un objeto
|
||||
@msg `{name}`: unknown key `{k}` in {_1} `{name}`: clave desconocida `{k}` en {_1}
|
||||
@msg `{name}`: give the kanji part `{name}`: indique la parte kanji
|
||||
@msg a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts un documento admite como máximo {COMPOSITE_FONTS_MAX} fuentes compuestas
|
||||
@msg two composite fonts are named `{_1}` dos fuentes compuestas se llaman `{_1}`
|
||||
@msg `{_1}` is the name of an installed font `{_1}` es el nombre de una fuente instalada
|
||||
@msg `{_1}`: {_2} is set in `{font}`, a composite font `{_1}`: {_2} usa `{font}`, una fuente compuesta
|
||||
@msg give fonts: [{name, parts}…] indique fonts: [{name, parts}…]
|
||||
@msg path is empty path está vacío
|
||||
@msg fonts must be a list of composite fonts fonts debe ser una lista de fuentes compuestas
|
||||
@msg names must be strings names debe contener cadenas
|
||||
@msg no composite font named `{n}` no hay ninguna fuente compuesta llamada `{n}`
|
||||
@msg names must be a list of names names debe ser una lista de nombres
|
||||
@msg no composite fonts to export no hay fuentes compuestas para exportar
|
||||
@msg not a composite fonts file: {e} no es un archivo de fuentes compuestas: {e}
|
||||
@msg not a composite fonts file: no compositeFonts list no es un archivo de fuentes compuestas: no hay lista compositeFonts
|
||||
@msg the file holds more than {COMPOSITE_FONTS_MAX} composite fonts el archivo contiene más de {COMPOSITE_FONTS_MAX} fuentes compuestas
|
||||
@msg the file is larger than 4 MB el archivo ocupa más de 4 MB
|
||||
@msg dataBase64 is not base64 dataBase64 no es base64
|
||||
@msg give one of path, data or dataBase64 indique path, data o dataBase64
|
||||
@msg the file holds no composite fonts el archivo no contiene fuentes compuestas
|
||||
@msg the file holds two composite fonts named `{_1}` el archivo contiene dos fuentes compuestas llamadas `{_1}`
|
||||
@msg `{_1}` can't name a composite font `{_1}` no puede nombrar una fuente compuesta
|
||||
@msg {k}: give a font {k}: indique una fuente
|
||||
@msg `{name}`: {k}: give a font `{name}`: {k}: indique una fuente
|
||||
@msg {k}: size must be {_1} to {_2}% {k}: size debe estar entre {_1} y {_2} %
|
||||
@msg `{name}`: {k}: size must be {_1} to {_2}% `{name}`: {k}: size debe estar entre {_1} y {_2} %
|
||||
@msg {k}: baseline must be {_1} to {_2}% {k}: baseline debe estar entre {_1} y {_2} %
|
||||
@msg `{name}`: {k}: baseline must be {_1} to {_2}% `{name}`: {k}: baseline debe estar entre {_1} y {_2} %
|
||||
@msg {k}: scale must be {_1} to {_2}% {k}: la escala debe estar entre {_1} y {_2} %
|
||||
@msg `{name}`: {k}: scale must be {_1} to {_2}% `{name}`: {k}: la escala debe estar entre {_1} y {_2} %
|
||||
@msg {k}: only kana, roman and numerals can be scaled {k}: solo kana, roman y numerals se pueden escalar
|
||||
@msg `{name}`: {k}: only kana, roman and numerals can be scaled `{name}`: {k}: solo kana, roman y numerals se pueden escalar
|
||||
@msg {k}: only kana can be scaled at the center {k}: solo kana se puede escalar desde el centro
|
||||
@msg `{name}`: {k}: only kana can be scaled at the center `{name}`: {k}: solo kana se puede escalar desde el centro
|
||||
@msg no composite font ninguna fuente compuesta
|
||||
Delete Font Eliminar fuente
|
||||
Kanji Kanji
|
||||
Kana Kana
|
||||
@@ -5967,6 +6012,7 @@ mark-style Japanese Japonesas
|
||||
@msg a 3D effect on a group's fill or stroke draws nothing to export un efecto 3D en el relleno o el trazo de un grupo no dibuja nada que exportar
|
||||
@msg The 3D object is too complex to export El objeto 3D es demasiado complejo para exportarlo
|
||||
@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) el objeto no tiene ningún efecto 3D (Extrusión y biselado, Revolución, Inflar o Rotar)
|
||||
@msg `render` must be an object holding only `mode`, set to shaded or wireframe `render` debe ser un objeto que solo contenga `mode`, con el valor shaded o wireframe
|
||||
@msg its 3D effect builds no solid su efecto 3D no construye ningún sólido
|
||||
@msg bevel must be none or one of {_1} bevel debe ser none o uno de estos: {_1}
|
||||
@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} no puede construir sólidos a partir de esta ilustración: sus instancias de símbolo, fusiones y grupos de recorte contienen demasiados objetos
|
||||
@@ -6643,6 +6689,8 @@ axis Y Axis Eje Y
|
||||
Create New Set Crear nuevo conjunto
|
||||
Stop Global Edit Detener edición global
|
||||
Global Edit Options Opciones de edición global
|
||||
Select Similar Objects Seleccionar objetos similares
|
||||
Select Similar Options Opciones de selección de similares
|
||||
Leave Out of Global Edit Excluir de la edición global
|
||||
Match Coincidir
|
||||
On Artboards En mesas de trabajo
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -523,6 +523,7 @@
|
||||
Rotate (Classic)… Kierrä (perinteinen)…
|
||||
Surface Pinta
|
||||
Wireframe Rautalanka
|
||||
Render Settings Renderöintiasetukset
|
||||
No Shading Ei varjostusta
|
||||
Plastic Muovi
|
||||
Highlight Intensity Kiillon voimakkuus
|
||||
@@ -1637,6 +1638,48 @@ digits Malayalam Malajalam
|
||||
Composite Fonts Yhdistelmäfontit
|
||||
Export Composite Fonts Vie yhdistelmäfontit
|
||||
Import Composite Fonts Tuo yhdistelmäfontit
|
||||
@msg each font must be {name, parts} jokaisen fontin on oltava {name, parts}
|
||||
@msg unknown key `{k}`; a font takes name and parts tuntematon avain `{k}`; fontti ottaa name ja parts
|
||||
@msg each font needs a name jokainen fontti tarvitsee nimen
|
||||
@msg `{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters) `{raw}` ei kelpaa yhdistelmäfontin nimeksi (1–{COMPOSITE_NAME_MAX} merkkiä, ei ohjausmerkkejä)
|
||||
@msg `{name}`: parts must be an object `{name}`: parts on oltava objekti
|
||||
@msg `{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals `{name}`: tuntematon luokka `{k}`; luokat ovat kanji, kana, punctuation, symbols, roman ja numerals
|
||||
@msg `{name}`: {_1} must be an object `{name}`: {_1} on oltava objekti
|
||||
@msg `{name}`: unknown key `{k}` in {_1} `{name}`: tuntematon avain `{k}` osassa {_1}
|
||||
@msg `{name}`: give the kanji part `{name}`: anna osa kanji
|
||||
@msg a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts asiakirjassa voi olla enintään {COMPOSITE_FONTS_MAX} yhdistelmäfonttia
|
||||
@msg two composite fonts are named `{_1}` kahden yhdistelmäfontin nimi on `{_1}`
|
||||
@msg `{_1}` is the name of an installed font `{_1}` on asennetun fontin nimi
|
||||
@msg `{_1}`: {_2} is set in `{font}`, a composite font `{_1}`: {_2} käyttää fonttia `{font}`, joka on yhdistelmäfontti
|
||||
@msg give fonts: [{name, parts}…] anna fonts: [{name, parts}…]
|
||||
@msg path is empty path on tyhjä
|
||||
@msg fonts must be a list of composite fonts fonts on oltava luettelo yhdistelmäfonteista
|
||||
@msg names must be strings names on oltava merkkijonoja
|
||||
@msg no composite font named `{n}` yhdistelmäfonttia `{n}` ei ole
|
||||
@msg names must be a list of names names on oltava luettelo nimistä
|
||||
@msg no composite fonts to export ei vietäviä yhdistelmäfontteja
|
||||
@msg not a composite fonts file: {e} ei yhdistelmäfonttitiedosto: {e}
|
||||
@msg not a composite fonts file: no compositeFonts list ei yhdistelmäfonttitiedosto: luettelo compositeFonts puuttuu
|
||||
@msg the file holds more than {COMPOSITE_FONTS_MAX} composite fonts tiedostossa on yli {COMPOSITE_FONTS_MAX} yhdistelmäfonttia
|
||||
@msg the file is larger than 4 MB tiedosto on suurempi kuin 4 Mt
|
||||
@msg dataBase64 is not base64 dataBase64 ei ole base64-muotoa
|
||||
@msg give one of path, data or dataBase64 anna path, data tai dataBase64
|
||||
@msg the file holds no composite fonts tiedostossa ei ole yhdistelmäfontteja
|
||||
@msg the file holds two composite fonts named `{_1}` tiedostossa on kaksi yhdistelmäfonttia nimeltä `{_1}`
|
||||
@msg `{_1}` can't name a composite font `{_1}` ei kelpaa yhdistelmäfontin nimeksi
|
||||
@msg {k}: give a font {k}: anna fontti
|
||||
@msg `{name}`: {k}: give a font `{name}`: {k}: anna fontti
|
||||
@msg {k}: size must be {_1} to {_2}% {k}: size on oltava {_1}–{_2} %
|
||||
@msg `{name}`: {k}: size must be {_1} to {_2}% `{name}`: {k}: size on oltava {_1}–{_2} %
|
||||
@msg {k}: baseline must be {_1} to {_2}% {k}: baseline on oltava {_1}–{_2} %
|
||||
@msg `{name}`: {k}: baseline must be {_1} to {_2}% `{name}`: {k}: baseline on oltava {_1}–{_2} %
|
||||
@msg {k}: scale must be {_1} to {_2}% {k}: skaalauksen on oltava {_1}–{_2} %
|
||||
@msg `{name}`: {k}: scale must be {_1} to {_2}% `{name}`: {k}: skaalauksen on oltava {_1}–{_2} %
|
||||
@msg {k}: only kana, roman and numerals can be scaled {k}: vain kana, roman ja numerals voidaan skaalata
|
||||
@msg `{name}`: {k}: only kana, roman and numerals can be scaled `{name}`: {k}: vain kana, roman ja numerals voidaan skaalata
|
||||
@msg {k}: only kana can be scaled at the center {k}: vain kana voidaan skaalata keskeltä
|
||||
@msg `{name}`: {k}: only kana can be scaled at the center `{name}`: {k}: vain kana voidaan skaalata keskeltä
|
||||
@msg no composite font ei yhdistelmäfonttia
|
||||
Delete Font Poista fontti
|
||||
Kanji Kanji
|
||||
Kana Kana
|
||||
@@ -2736,6 +2779,8 @@ digits Malayalam Malajalam
|
||||
Keep a link to an image file instead of only embedding it Säilytä linkki kuvatiedostoon sen sijaan, että kuva vain upotetaan
|
||||
Place onto a new locked, dimmed template layer below the current layer Sijoita uudelle lukitulle, himmennetylle mallitasolle nykyisen tason alapuolelle
|
||||
Swap the selected object for the file, keeping its place and transform Korvaa valittu objekti tiedostolla säilyttäen sen paikan ja muunnoksen
|
||||
Show Import Options Näytä tuontiasetukset
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Kysy kunkin PDF-, DXF- tai tekstitiedoston tuontiasetukset ennen sijoittamista; pois: käytä viimeksi valittuja asetuksia
|
||||
On: one locked object showing the document, updated when it changes. Päällä: yksi lukittu objekti, joka näyttää asiakirjan ja päivittyy sen muuttuessa.
|
||||
Off: an editable copy of its art, embedded in this document. Pois: muokattava kopio sen kuvituksesta upotettuna tähän asiakirjaan.
|
||||
Images: link to the file instead of only embedding it. Kuvat: linkitä tiedostoon sen sijaan, että se vain upotetaan.
|
||||
@@ -5909,6 +5954,7 @@ mark-style Japanese Japanilainen
|
||||
@msg a 3D effect on a group's fill or stroke draws nothing to export ryhmän täytön tai viivan 3D-tehoste ei piirrä mitään vietävää
|
||||
@msg The 3D object is too complex to export 3D-objekti on liian monimutkainen vietäväksi
|
||||
@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) objektilla ei ole 3D-tehostetta (Pursota ja viistä, Pyöräytä, Täytä ilmalla tai Kierrä)
|
||||
@msg `render` must be an object holding only `mode`, set to shaded or wireframe `render` on oltava objekti, jossa on vain `mode` arvolla shaded tai wireframe
|
||||
@msg its 3D effect builds no solid sen 3D-tehoste ei muodosta kappaletta
|
||||
@msg bevel must be none or one of {_1} bevel-arvon on oltava none tai jokin seuraavista: {_1}
|
||||
@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} ei voi muodostaa kappaleita tästä kuvituksesta: sen symbolien esiintymät, sulautukset ja leikkausryhmät sisältävät liikaa objekteja
|
||||
@@ -6732,6 +6778,8 @@ axis Y Axis Y-akseli
|
||||
Create New Set Luo uusi sarja
|
||||
Stop Global Edit Lopeta yleismuokkaus
|
||||
Global Edit Options Yleismuokkauksen asetukset
|
||||
Select Similar Objects Valitse samankaltaiset objektit
|
||||
Select Similar Options Samankaltaisten valinnan asetukset
|
||||
Leave Out of Global Edit Jätä pois yleismuokkauksesta
|
||||
Match Vastaavuus
|
||||
On Artboards Piirtoalustoilla
|
||||
|
||||
|
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
Reference in New Issue
Block a user