mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
G12: integrate current main and panel view state
Resolve documentation and upstream test-helper conflicts, preserve the upstream helper fix, and adapt context-menu harness views to app state. All 13 Linux CI gates pass on this integrated source.
This commit is contained in:
+3
-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-10 · **Change:** minor (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-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)
|
||||
|
||||
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
|
||||
@@ -125,6 +125,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
## Progress log
|
||||
|
||||
- 2026-11-14: Built-in symbol library Weather Icons: adds a sixth procedural symbol library (Window › Symbol Libraries, the Symbols panel's Symbol Libraries Menu) containing six original vector symbols (Sun, Moon, Cloud, Rain, Thunder, Snow) generated in code without default 9-slice guides (part of Gap 5). Headline numbers unchanged.
|
||||
- 2026-11-14: Built-in Tech Circuit brush library: Circuit Trace (chamfered art with 45-degree elbows and terminal pads), Bus Lines (parallel conductor pattern) and Terminal Nodes (repeating solder pads, test points and leads), all original geometry generated in code. Ten built-in brush libraries; G5 remaining estimate 3–13 h (estimated), headline numbers unchanged.
|
||||
|
||||
- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged.
|
||||
- 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged.
|
||||
@@ -224,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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. |
|
||||
| 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. |
|
||||
|
||||
@@ -120,6 +120,7 @@ pub use style_libs::StyleLibrary;
|
||||
pub use text::FontHeightReference;
|
||||
pub use text::GlyphChoice;
|
||||
pub use text::GlyphForm;
|
||||
pub use text::PATH_LEAD_MAX;
|
||||
pub use text::WarichuAlign;
|
||||
pub use text::{
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
|
||||
|
||||
@@ -79,6 +79,21 @@ fn odd_text_reads_as_nothing_without_panicking() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text from a command or a file can be any length: a sum of thousands of terms reads as nothing
|
||||
/// instead of exhausting the stack. Up to 128 `+ - * /` signs read; 129 read as nothing.
|
||||
#[test]
|
||||
fn a_very_long_expression_reads_as_nothing() {
|
||||
let sum = |terms: usize| format!("1{}", "+1".repeat(terms - 1));
|
||||
assert_eq!(Unit::Points.parse(&sum(129)), Some(129.0));
|
||||
assert_eq!(Unit::Points.parse(&format!("10{} mm", "*1".repeat(128))), Some(Unit::Millimeters.to_pt(10.0)));
|
||||
// Long text with no signs still reads.
|
||||
assert_eq!(Unit::Points.parse(&format!("{}5", " ".repeat(2_000))), Some(5.0));
|
||||
for text in [sum(200_000), format!("1{}", "*1".repeat(200_000)), sum(130), format!("10{} mm", "*1".repeat(129))] {
|
||||
assert_eq!(Unit::Points.parse(&text), None, "{} bytes", text.len());
|
||||
assert_eq!(crate::parse_number(&text), None, "{} bytes", text.len());
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Field text made of digits, operators, `%`, spaces and unit letters reads as a finite value
|
||||
/// or as nothing and does not panic.
|
||||
|
||||
@@ -29,6 +29,7 @@ pub use para_style::{
|
||||
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel,
|
||||
Mojikumi, ParaDirection, ParaStyle, SingleWordJustify,
|
||||
};
|
||||
pub use runs::PATH_LEAD_MAX;
|
||||
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef, TypeAntiAlias};
|
||||
pub use tabs_lists::{
|
||||
Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign,
|
||||
|
||||
@@ -352,7 +352,10 @@ impl TextObject {
|
||||
}
|
||||
/// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its
|
||||
/// centre bracket dragged across the path): the path runs the other way and the brackets swap
|
||||
/// ends, so the type keeps its stretch of the path. False, changing nothing, for other type.
|
||||
/// ends, so the type keeps its stretch of the path. Type that starts before an open path's start
|
||||
/// ([`TextKind::path_text_start`]) starts at its start bracket after the flip: the part of its
|
||||
/// span before the path's start would come after the path's end. False, changing nothing, for
|
||||
/// other type.
|
||||
pub fn flip_on_path(&mut self) -> bool {
|
||||
let Some((s, e)) = self.kind.path_span() else { return false };
|
||||
let TextKind::OnPath { path, start, end } = &mut self.kind else { return false };
|
||||
|
||||
@@ -93,8 +93,10 @@ pub enum TextKind {
|
||||
/// Area type flowed inside `frame` (document coordinates, untransformed by `xf`).
|
||||
Area { frame: PathData },
|
||||
/// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the
|
||||
/// path length; no `end`: the end of the path, or once round a closed path). See
|
||||
/// [`TextKind::path_span`].
|
||||
/// path length; no `end`: the end of the path, or once round a closed path). On an open path
|
||||
/// `start` may be below 0: the type starts that far before the path's start, and the characters
|
||||
/// whose middle falls before the path's start are not drawn ([`TextKind::path_text_start`]).
|
||||
/// See [`TextKind::path_span`].
|
||||
OnPath {
|
||||
path: PathData,
|
||||
start: f64,
|
||||
@@ -117,8 +119,22 @@ impl TextKind {
|
||||
(s, end.map_or(1.0, |e| fraction(e).max(s)))
|
||||
})
|
||||
}
|
||||
/// Where type on a path's text starts, as a fraction of its path's length: its start bracket
|
||||
/// ([`Self::path_span`]), or on an open path a start before the path's start (below 0, at most
|
||||
/// [`PATH_LEAD_MAX`] path lengths before it). None for other type.
|
||||
pub fn path_text_start(&self) -> Option<f64> {
|
||||
let TextKind::OnPath { path, start, .. } = self else { return None };
|
||||
if !path.is_closed() && start.is_finite() && *start < 0.0 { Some(start.max(-PATH_LEAD_MAX)) } else { self.path_span().map(|(s, _)| s) }
|
||||
}
|
||||
/// Does type on an open path start before its path's start ([`Self::path_text_start`])?
|
||||
pub fn starts_before_path(&self) -> bool {
|
||||
self.path_text_start().is_some_and(|s| s < 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far before its path's start type on an open path may start, in lengths of the path.
|
||||
pub const PATH_LEAD_MAX: f64 = 1e6;
|
||||
|
||||
/// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the
|
||||
/// document → text map). False when an anchor doesn't exist.
|
||||
pub(super) fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool {
|
||||
|
||||
@@ -563,6 +563,33 @@ fn area_anchors_move_in_document_space() {
|
||||
assert_eq!(t, before);
|
||||
}
|
||||
|
||||
/// Type on an open path may start before the path's start: the start is kept, saved and read back,
|
||||
/// while the start bracket stays at the path's start. On a closed path, and for a start that is not
|
||||
/// finite, the start is the start bracket as before.
|
||||
#[test]
|
||||
fn type_on_an_open_path_starts_before_the_path() {
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)];
|
||||
let on = |closed: bool, start: f64| TextKind::OnPath {
|
||||
path: PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, closed)]),
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
let open = on(false, -0.25);
|
||||
assert_eq!(open.path_text_start(), Some(-0.25));
|
||||
assert!(open.starts_before_path());
|
||||
assert_eq!(open.path_span(), Some((0.0, 1.0)));
|
||||
let saved = serde_json::to_value(&open).unwrap();
|
||||
assert_eq!(saved["start"], -0.25);
|
||||
assert_eq!(serde_json::from_value::<TextKind>(saved).unwrap(), open);
|
||||
assert_eq!(on(false, -1e9).path_text_start(), Some(-PATH_LEAD_MAX));
|
||||
assert_eq!(on(false, f64::NEG_INFINITY).path_text_start(), Some(0.0));
|
||||
assert_eq!(on(false, 0.25).path_text_start(), Some(0.25));
|
||||
assert!(!on(false, 0.0).starts_before_path());
|
||||
assert_eq!(on(true, -0.25).path_text_start(), Some(0.0));
|
||||
assert!(!on(true, -0.25).starts_before_path());
|
||||
assert_eq!(TextKind::Point.path_text_start(), None);
|
||||
}
|
||||
|
||||
/// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore
|
||||
/// `justify_auto`); this build reads Auto back.
|
||||
#[test]
|
||||
|
||||
@@ -156,7 +156,12 @@ impl Unit {
|
||||
/// before it (`3cm*50%` is 1.5 cm, `3cm/50%` is 6 cm). After `+` or `-`, it adds or takes away
|
||||
/// that share of everything before it (`50pt+25%` is 62.5 pt, `100pt+20pt-50%` is 60 pt). A
|
||||
/// percentage on its own (`50%`) reads as nothing.
|
||||
///
|
||||
/// Text with more than 128 `+ - * /` signs reads as nothing.
|
||||
pub fn parse(self, s: &str) -> Option<f64> {
|
||||
if s.chars().filter(|c| matches!(c, '+' | '-' | '*' | '/')).count() > MAX_MEASURE_OPERATORS {
|
||||
return None;
|
||||
}
|
||||
parse_measure(s, self).filter(|v| v.is_finite())
|
||||
}
|
||||
/// The value naming this unit in the Units preferences (`points`, `millimeters`,
|
||||
@@ -207,6 +212,9 @@ impl Unit {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most `+ - * /` signs [`Unit::parse`] reads in one text.
|
||||
const MAX_MEASURE_OPERATORS: usize = 128;
|
||||
|
||||
fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
// Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2").
|
||||
|
||||
@@ -417,7 +417,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"artistic.roughPastels",
|
||||
"Rough Pastels…",
|
||||
ARTISTIC,
|
||||
"{strokeLength: 0..40 (6; longer pastel strokes), strokeDetail: 1..20 (10; bristle texture along the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false)} repaints the object in soft diagonal pastel strokes over a textured surface, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults.",
|
||||
"{strokeLength: 0..40 (6; longer pastel strokes), strokeDetail: 1..20 (10; bristle texture along the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\"|\"loaded\" (\"canvas\"; \"loaded\" draws the image in textureImage and draws Canvas when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false)} repaints the object in soft diagonal pastel strokes over a textured surface, keeping its coverage. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults.",
|
||||
json!({
|
||||
"strokeLength": 6.0,
|
||||
"strokeDetail": 10.0,
|
||||
|
||||
@@ -155,8 +155,9 @@ pub enum PixelFx {
|
||||
/// `texture_coverage` (0..40) says.
|
||||
Underpainting { brush_size: f64, texture_coverage: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
|
||||
/// Artistic › Rough Pastels: diagonal pastel strokes `length` (0..40) long, bristle detail
|
||||
/// (1..20), then a [`Texture`] surface scaled by `scaling` (0.5..2), in relief as strong as
|
||||
/// `relief` (0..50) and lit from `light`; `invert` turns the surface's heights over.
|
||||
/// (1..20), then a [`Texture`] surface (a loaded one included) scaled by `scaling` (0.5..2), in
|
||||
/// relief as strong as `relief` (0..50) and lit from `light`; `invert` turns the surface's
|
||||
/// heights over.
|
||||
RoughPastels { length: f64, detail: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
|
||||
/// Blur › Radial Blur around its Blur Center, `center` (0..1 across and down the object's
|
||||
/// bounds; (0.5, 0.5) the object's centre): along arcs (spin, an arc of `amount`°) or along
|
||||
@@ -385,7 +386,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
"artistic.roughPastels" => PixelFx::RoughPastels {
|
||||
length: num(p, "strokeLength", 6.0).clamp(0.0, 40.0),
|
||||
detail: num(p, "strokeDetail", 10.0).clamp(1.0, 20.0),
|
||||
texture: Texture::parse(text(p, "texture", "canvas")),
|
||||
texture: Texture::parse_with(text(p, "texture", "canvas"), p.get(TEXTURE_IMAGE).and_then(Value::as_str)),
|
||||
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
|
||||
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
|
||||
light: Light::parse(text(p, "lightDirection", "top")),
|
||||
|
||||
@@ -1547,6 +1547,42 @@ fn texturizer_draws_a_loaded_texture_as_relief() {
|
||||
assert_eq!(fx("texture.texturizer", json!({"texture": "LOADED", "textureImage": "not a texture"})), fx("texture.texturizer", json!({})));
|
||||
}
|
||||
|
||||
/// Rough Pastels draws a loaded texture as its surface, and Scaling, Relief, Light Direction and
|
||||
/// Invert act on it as on the built-in ones.
|
||||
#[test]
|
||||
fn rough_pastels_draws_a_loaded_texture_as_its_surface() {
|
||||
let w = 64;
|
||||
let mid = grey(150);
|
||||
let rings = stored(12, 10, |x, y| {
|
||||
let v = ((x as f64 - 5.5).hypot(y as f64 - 4.5) * 0.9).sin().abs();
|
||||
let g = (v * 255.0).round() as u8;
|
||||
[g, g, g, 255]
|
||||
});
|
||||
let run = |mut p: serde_json::Value| {
|
||||
p["strokeLength"] = json!(0);
|
||||
let mut d = flat(w, mid);
|
||||
fx("artistic.roughPastels", p).apply(&mut d, w, w, &space(w, w));
|
||||
d
|
||||
};
|
||||
let relief = run(json!({"texture": "loaded", "textureImage": rings, "relief": 20}));
|
||||
assert_ne!(relief, flat(w, mid));
|
||||
for (_, builtin) in TEXTURES {
|
||||
assert_ne!(relief, run(json!({"texture": builtin, "relief": 20})), "{builtin}");
|
||||
}
|
||||
for (key, value) in [("invert", json!(true)), ("scaling", json!(200)), ("lightDirection", json!("bottomRight")), ("relief", json!(5))] {
|
||||
let mut p = json!({"texture": "loaded", "textureImage": rings, "relief": 20});
|
||||
p[key] = value;
|
||||
assert_ne!(run(p), relief, "{key}");
|
||||
}
|
||||
assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "relief": 0})), flat(w, mid));
|
||||
let parsed = fx("artistic.roughPastels", json!({"texture": "Loaded", "textureImage": rings}));
|
||||
assert!(matches!(parsed, PixelFx::RoughPastels { texture: Texture::Loaded(_), .. }), "{parsed:?}");
|
||||
// Without a stored texture, or with one that isn't, Loaded draws Canvas.
|
||||
for p in [json!({"texture": "loaded"}), json!({"texture": "loaded", "textureImage": "not a texture"})] {
|
||||
assert_eq!(fx("artistic.roughPastels", p), fx("artistic.roughPastels", json!({})));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Texture filters' patterns lie in document space around the object's centre: the same at
|
||||
/// twice the resolution, and moving with the object.
|
||||
#[test]
|
||||
|
||||
@@ -114,7 +114,7 @@ pub fn listed(held: &[SvgFilter]) -> (Vec<Listed>, usize) {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::*;
|
||||
use crate::pixel::{Channels, PixelSpace};
|
||||
@@ -128,7 +128,7 @@ mod tests {
|
||||
to_doc: Affine::scale(1.0),
|
||||
px: 1.0,
|
||||
center: Point::new(wd / 2.0, wd / 2.0),
|
||||
size: Vec2::new(wd, wd),
|
||||
size: vectorcraft_geom::Vec2::new(wd, wd),
|
||||
channels: Channels::Rgb,
|
||||
line: 1.0,
|
||||
extent: 0.5 * wd.hypot(wd),
|
||||
|
||||
@@ -531,8 +531,13 @@ fn an_anchor_edit_makes_it_a_plain_path_and_transforms_keep_it_editable() {
|
||||
s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 6})).unwrap();
|
||||
assert_eq!((path(&s, id).anchor_count(), steps(&s)), (12, n + 1));
|
||||
assert!((shape_of(&s, id).unwrap().rotation + 30.0).abs() < 1e-6, "the turn stays");
|
||||
// Stretched across its own axes (it is turned): skewed, so no longer.
|
||||
// Stretched along its turned bounding box: still the shape, wider along its own axis.
|
||||
s.execute("object.scale", &json!({"sx": 150, "sy": 100, "origin": [220, 140]})).unwrap();
|
||||
let r = shape_of(&s, id).unwrap();
|
||||
assert!((r.width - 300.0).abs() < 1e-6 && (r.height - 200.0).abs() < 1e-6 && (r.rotation + 30.0).abs() < 1e-6, "{r:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Stretched across the page's axes (it is turned): skewed, so no longer.
|
||||
s.execute("object.scale", &json!({"sx": 150, "sy": 100, "origin": [220, 140], "basis": "page"})).unwrap();
|
||||
assert_eq!(shape_of(&s, id), None);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(shape_of(&s, id).is_some(), "undo brings it back");
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Release Type on a Path…",
|
||||
["Object", "Plug-ins", "Type Tools"],
|
||||
None,
|
||||
"{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
|
||||
"{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path (when the type starts before an open path's start, the first character drawn on the path stays where it was drawn and the characters that were not drawn come before it); Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
|
||||
has_selection,
|
||||
release_path
|
||||
),
|
||||
@@ -229,6 +229,28 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option<Point> {
|
||||
lay.glyphs.iter().filter(|g| g.origin.x.is_finite() && g.origin.y.is_finite()).min_by_key(|g| (g.byte, g.len != 0)).map(|g| g.origin)
|
||||
}
|
||||
|
||||
/// A glyph of a character: its byte, whether it stands for characters (a list marker does not),
|
||||
/// and its place among the glyphs of that byte and kind (an All Caps ß is two glyphs).
|
||||
type GlyphKey = (usize, bool, usize);
|
||||
|
||||
/// The key and pen position of the first glyph drawn in `lay`, in text order: characters before an
|
||||
/// open path's start are not drawn ([`vectorcraft_text::PositionedGlyph::off_path`]). None without
|
||||
/// a drawn glyph.
|
||||
fn first_drawn_pen(lay: &vectorcraft_text::TextLayout) -> Option<(GlyphKey, Point)> {
|
||||
let (gi, g) = (lay.glyphs.iter().enumerate())
|
||||
.filter(|(_, g)| !g.off_path && g.origin.x.is_finite() && g.origin.y.is_finite())
|
||||
.min_by_key(|(gi, g)| (g.byte, g.len != 0, *gi))?;
|
||||
let kind = g.len != 0;
|
||||
let nth = lay.glyphs.iter().take(gi).filter(|h| h.byte == g.byte && (h.len != 0) == kind).count();
|
||||
Some(((g.byte, kind, nth), g.origin))
|
||||
}
|
||||
|
||||
/// The pen position of the glyph `key` names in `lay`. None without one.
|
||||
fn pen_at(lay: &vectorcraft_text::TextLayout, (byte, kind, nth): GlyphKey) -> Option<Point> {
|
||||
let g = lay.glyphs.iter().filter(|g| g.byte == byte && (g.len != 0) == kind).nth(nth)?;
|
||||
(g.origin.x.is_finite() && g.origin.y.is_finite()).then_some(g.origin)
|
||||
}
|
||||
|
||||
/// `t`, type on a path, as point type on one straight line: the same runs and character and
|
||||
/// paragraph attributes, its baseline starting where its first character sat on the path (in the
|
||||
/// object's own text space, so its transform stays). Type on a Path Options are dropped, and
|
||||
@@ -237,13 +259,14 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option<Point> {
|
||||
fn released_text(t: &TextObject) -> Option<TextObject> {
|
||||
let TextKind::OnPath { path, .. } = &t.kind else { return None };
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// Where the first character sat. Text that didn't fit on the path has no glyph there: it
|
||||
// starts at the start bracket.
|
||||
let at = first_pen(&vectorcraft_text::layout(db, t)).or_else(|| {
|
||||
// Where the first drawn character sat; characters before an open path's start come before it
|
||||
// on the line. Text that didn't fit on the path has no glyph there: it starts at the start
|
||||
// bracket.
|
||||
let (key, at) = first_drawn_pen(&vectorcraft_text::layout(db, t)).map(|(b, p)| (Some(b), p)).or_else(|| {
|
||||
let ap = ArcPath::new(path);
|
||||
let (from, _) = t.kind.path_span()?;
|
||||
let (p, _) = ap.at(from * ap.len());
|
||||
(p.x.is_finite() && p.y.is_finite()).then_some(p)
|
||||
(p.x.is_finite() && p.y.is_finite()).then_some((None, p))
|
||||
})?;
|
||||
let mut out = t.clone();
|
||||
// Point type has no brackets to fit: it keeps the sizes the path drew it at.
|
||||
@@ -254,9 +277,10 @@ fn released_text(t: &TextObject) -> Option<TextObject> {
|
||||
out.path_spacing = 0.0;
|
||||
// Point type is in no thread: its first paragraph starts in it.
|
||||
out.first_para_continues = false;
|
||||
// Where the point type puts its own first character; the origin moves so it lands at `at`,
|
||||
// whatever the paragraph's alignment and direction.
|
||||
let own = first_pen(&vectorcraft_text::layout(db, &out)).unwrap_or(Point::ZERO);
|
||||
// Where the point type puts that glyph; the origin moves so it lands at `at`, whatever the
|
||||
// paragraph's alignment and direction.
|
||||
let own_lay = vectorcraft_text::layout(db, &out);
|
||||
let own = key.and_then(|k| pen_at(&own_lay, k)).or_else(|| first_pen(&own_lay)).unwrap_or(Point::ZERO);
|
||||
let shift = at - own;
|
||||
if !(shift.x.is_finite() && shift.y.is_finite()) {
|
||||
return None;
|
||||
|
||||
@@ -482,6 +482,44 @@ mod release {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type that starts before its open path: the first drawn character stays where it was drawn,
|
||||
/// and the characters that were not drawn come before it on the line.
|
||||
#[test]
|
||||
fn type_starting_before_its_open_path_keeps_its_drawn_characters_in_place() {
|
||||
let mut s = session();
|
||||
let id = path_type(&mut s, "M100 300 L500 300", [100.0, 300.0], "Hidden start");
|
||||
s.execute("type.pathOptions", &json!({"start": -0.05})).unwrap();
|
||||
let before = text(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &before);
|
||||
let first = lay.glyphs.iter().filter(|g| !g.off_path).min_by_key(|g| g.byte).unwrap();
|
||||
let (byte, drawn) = (first.byte, before.xf * first.origin);
|
||||
assert!(byte > 0, "some characters are before the path");
|
||||
s.execute(C, &json!({"mode": "text"})).unwrap();
|
||||
let after = text(&s, id);
|
||||
assert_eq!(after.kind, TextKind::Point);
|
||||
assert_eq!(after.plain_text(), before.plain_text());
|
||||
let back = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &after);
|
||||
let same = back.glyphs.iter().find(|g| g.byte == byte).unwrap();
|
||||
assert!(close(after.xf * same.origin, drawn), "{:?} vs {drawn:?}", after.xf * same.origin);
|
||||
let lead = back.glyphs.iter().find(|g| g.byte == 0).unwrap();
|
||||
assert!((after.xf * lead.origin).x < drawn.x, "the hidden characters come before it");
|
||||
// An All Caps ß is two glyphs: with the first before the path's start, the second stays
|
||||
// where it was drawn.
|
||||
let id = path_type(&mut s, "M100 400 L500 400", [100.0, 400.0], "ße");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "allCaps": true})).unwrap();
|
||||
let adv = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)).glyphs[0].advance;
|
||||
s.execute("type.pathOptions", &json!({"start": -1.2 * adv / 400.0})).unwrap();
|
||||
let before = text(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &before);
|
||||
assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path);
|
||||
let drawn = before.xf * lay.glyphs[1].origin;
|
||||
s.execute(C, &json!({"mode": "text"})).unwrap();
|
||||
let after = text(&s, id);
|
||||
let back = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &after);
|
||||
let second = back.glyphs.iter().filter(|g| g.byte == 0).nth(1).unwrap();
|
||||
assert!(close(after.xf * second.origin, drawn), "{:?} vs {drawn:?}", after.xf * second.origin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipped_type_releases_its_path_as_it_runs() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Fill",
|
||||
["Window", "Appearance"],
|
||||
Some("Cmd+/"),
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, true)
|
||||
),
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Stroke",
|
||||
["Window", "Appearance"],
|
||||
Some("Cmd+Alt+/"),
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, false)
|
||||
),
|
||||
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clear Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
clear_appearance
|
||||
),
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reduce to Basic Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
reduce_basic
|
||||
),
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), 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)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), 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)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
set_item
|
||||
),
|
||||
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Item",
|
||||
[],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, 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)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, 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)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
remove_item
|
||||
),
|
||||
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Effect",
|
||||
[],
|
||||
None,
|
||||
"{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}",
|
||||
"{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?, aligned?} → {ids, index, item}",
|
||||
has_doc,
|
||||
super::effectcmd::apply
|
||||
),
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Item",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, 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)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, 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)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
duplicate_item
|
||||
),
|
||||
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reorder Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}",
|
||||
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ, or differ from that of a graph design's art given first)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}",
|
||||
has_doc,
|
||||
move_item
|
||||
),
|
||||
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eyedropper",
|
||||
[],
|
||||
None,
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}",
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}",
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Show All Hidden Attributes",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}",
|
||||
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}",
|
||||
has_doc,
|
||||
show_all_hidden
|
||||
),
|
||||
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Appearance",
|
||||
[],
|
||||
None,
|
||||
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}",
|
||||
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}",
|
||||
has_doc,
|
||||
transfer
|
||||
),
|
||||
@@ -183,14 +183,21 @@ pub(crate) struct ActiveItem {
|
||||
impl Session {
|
||||
/// The method returns explicit target parameters and the selected appearance effect list.
|
||||
pub fn appearance_effect_target(&self, params: &Value) -> Option<(Value, &[vectorcraft_doc::Effect])> {
|
||||
let mut ids = appearance_targets(self, params).ok()?;
|
||||
let mut ids = drop_design_art(&self.active()?.doc, appearance_targets(self, params).ok()?);
|
||||
let item = item_target(self, params, "effect.apply").ok()?;
|
||||
let anchor = item.anchor(self, &ids);
|
||||
let node = self.active()?.doc.node(*ids.get(anchor)?)?;
|
||||
// Aligned effects given with a graph design's art first are effects of the art's stack, which stays first; the
|
||||
// Appearance panel's active row is a row of the first selected object's.
|
||||
let of = match item {
|
||||
ItemTarget::Item { explicit: false, .. } => None,
|
||||
_ => design_reference(self, params, "effect.apply").ok()?,
|
||||
};
|
||||
let own = *ids.get(anchor)?;
|
||||
let node = self.active()?.doc.node(of.unwrap_or(own))?;
|
||||
let at = item.effects_item(&node.appearance, "effect.apply").ok()?;
|
||||
let effects = node.appearance.effects_at(at)?;
|
||||
ids.swap(0, anchor);
|
||||
let ids: Vec<u64> = ids.into_iter().map(|id| id.0).collect();
|
||||
let ids: Vec<u64> = with_reference(of, &ids).into_iter().map(|id| id.0).collect();
|
||||
let aligned = match item {
|
||||
ItemTarget::Item { aligned, .. } => aligned,
|
||||
ItemTarget::Top => aligned(params, "effect.apply").ok()?,
|
||||
@@ -253,6 +260,10 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))),
|
||||
// The active item is a row of the first selected object's own stack.
|
||||
None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top),
|
||||
// A row of a graph design's art in a series stands for nothing: the commands leave that art out.
|
||||
None if s.active().and_then(|st| st.selection.subjects().first().map(|id| super::graph::is_design_art(&st.doc, *id))) == Some(true) => {
|
||||
Ok(ItemTarget::Top)
|
||||
}
|
||||
None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false, aligned: true })),
|
||||
}
|
||||
}
|
||||
@@ -300,6 +311,21 @@ impl ItemTarget {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`ItemTarget::per_target`], with an explicit aligned item a row of the stack of `of` (see [`design_reference`]),
|
||||
/// which is not among `ids`, when there is one: each target edits the item equal to it there, and a target without
|
||||
/// an equal item is left out (`None`).
|
||||
pub(crate) fn per_target_of(self, s: &Session, of: Option<NodeId>, ids: &[NodeId]) -> Vec<Option<ItemTarget>> {
|
||||
let ItemTarget::Item { index, explicit: true, aligned: true } = self else { return self.per_target(s, ids) };
|
||||
let Some(d) = s.active().map(|st| &st.doc) else { return self.per_target(s, ids) };
|
||||
let Some(r) = of.and_then(|of| d.node(of)) else { return self.per_target(s, ids) };
|
||||
ids.iter()
|
||||
.map(|id| {
|
||||
let index = SharedStack::of(&[&*r.appearance, &*d.node(*id)?.appearance]).item_in(index, 1)?;
|
||||
Some(ItemTarget::Item { index, explicit: true, aligned: true })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of
|
||||
/// is: the first selected object (or the compound path holding it) for the active item, which
|
||||
/// need not paint first; the first target otherwise, or when that object is not among them.
|
||||
@@ -384,6 +410,32 @@ pub(crate) fn items_per_target(d: &vectorcraft_doc::Document, ids: &[NodeId], id
|
||||
}
|
||||
}
|
||||
|
||||
/// The art of a graph design in a graph series whose stack the aligned rows of `p` (`aligned: true`) are rows of: the
|
||||
/// first of the [`appearance_targets`] when it is such art, which the edit leaves out ([`without_design_art`]). A
|
||||
/// command reads it after its own target checks.
|
||||
pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result<Option<NodeId>> {
|
||||
if !aligned(p, cmd)? {
|
||||
return Ok(None);
|
||||
}
|
||||
let ids = appearance_targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(ids.first().copied().filter(|id| super::graph::is_design_art(d, *id)))
|
||||
}
|
||||
|
||||
/// `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()
|
||||
}
|
||||
|
||||
/// [`items_per_target`] of `ids`, with the aligned rows `idx` taken from the stack of `of` (see [`design_reference`])
|
||||
/// when there is one: a row a target has no equal of is left out of that target.
|
||||
pub(crate) fn rows_per_target(d: &Document, of: Option<NodeId>, ids: &[NodeId], idx: &[usize], aligned: bool) -> Vec<Vec<usize>> {
|
||||
match of {
|
||||
Some(of) => items_per_target(d, &with_reference(Some(of), ids), idx, true).into_iter().skip(1).collect(),
|
||||
None => items_per_target(d, ids, idx, aligned),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the `target` param asks for the contents of groups and layers (see the module docs).
|
||||
fn targets_contents(p: &Value) -> Result<bool> {
|
||||
match p.get("target") {
|
||||
@@ -424,22 +476,44 @@ const DESIGN_ART: &str = "the art of a graph design in a graph series is drawn a
|
||||
|
||||
/// The [`appearance_targets`] whose rows and effects a command edits: the art of graph designs drawn in graph series
|
||||
/// ([`super::graph::is_design_art`]) is left out, as the graph draws it again from the design. A command whose targets
|
||||
/// are all such art is refused.
|
||||
pub(crate) fn edit_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
/// are all such art is refused as an error of `cmd`.
|
||||
pub(crate) fn edit_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let ids = appearance_targets(s, p)?;
|
||||
without_design_art(&s.doc()?.doc, ids, "appearance")
|
||||
without_design_art(&s.doc()?.doc, ids, cmd)
|
||||
}
|
||||
|
||||
/// `ids` without the art of graph designs drawn in graph series; `cmd` is refused when `ids` held nothing else.
|
||||
pub(crate) fn without_design_art(d: &Document, mut ids: Vec<NodeId>, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
pub(crate) fn without_design_art(d: &Document, ids: Vec<NodeId>, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let given = ids.len();
|
||||
ids.retain(|id| !super::graph::is_design_art(d, *id));
|
||||
let ids = drop_design_art(d, ids);
|
||||
if given > 0 && ids.is_empty() {
|
||||
return Err(bad(cmd, DESIGN_ART));
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// `ids` without the art of graph designs drawn in graph series, for the paint commands, which leave it out without an
|
||||
/// error.
|
||||
pub(crate) fn drop_design_art(d: &Document, mut ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
ids.retain(|id| !super::graph::is_design_art(d, *id));
|
||||
ids
|
||||
}
|
||||
|
||||
/// The outermost art of graph designs drawn in graph series inside `roots`: the objects directly in a series group that
|
||||
/// [`super::graph::is_design_art`] accepts.
|
||||
pub(crate) fn design_art_in(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = vec![];
|
||||
for id in roots {
|
||||
let Some(n) = d.node(*id) else { continue };
|
||||
n.walk(&mut |m| {
|
||||
if m.extras().series_index.is_some() {
|
||||
out.extend(m.children().into_iter().flatten().map(|c| c.id).filter(|c| super::graph::is_design_art(d, *c)));
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside
|
||||
/// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays:
|
||||
/// the Appearance panel lists those rows and edits them by index.
|
||||
@@ -476,15 +550,15 @@ pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node {
|
||||
|
||||
/// Objects whose fill and stroke rows a command edits: the [`edit_targets`], with a graph series group standing for the
|
||||
/// paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit.
|
||||
pub(crate) fn row_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let ids = edit_targets(s, p)?;
|
||||
pub(crate) fn row_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let ids = edit_targets(s, p, cmd)?;
|
||||
Ok(rowless_series_marks(&s.doc()?.doc, &ids))
|
||||
}
|
||||
|
||||
/// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for,
|
||||
/// which can hold effects and a graphic style link of its own.
|
||||
pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let own = edit_targets(s, p)?;
|
||||
pub(crate) fn whole_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let own = edit_targets(s, p, cmd)?;
|
||||
let mut ids = rowless_series_marks(&s.doc()?.doc, &own);
|
||||
let groups = stood_for(&own, &ids);
|
||||
ids.extend(groups);
|
||||
@@ -513,7 +587,7 @@ pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) -> bool {
|
||||
|
||||
/// [`whole_targets`] of `cmd`, which needs at least one.
|
||||
fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let ids = whole_targets(s, p)?;
|
||||
let ids = whole_targets(s, p, cmd)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select objects or give ids"));
|
||||
}
|
||||
@@ -522,7 +596,7 @@ fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId
|
||||
|
||||
/// [`row_targets`] of `cmd`, which needs at least one.
|
||||
fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let ids = row_targets(s, p)?;
|
||||
let ids = row_targets(s, p, cmd)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select objects or give ids"));
|
||||
}
|
||||
@@ -608,8 +682,8 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo
|
||||
return Ok(b);
|
||||
}
|
||||
let ItemTarget::Item { index, .. } = item else { return Ok(default) };
|
||||
let ids = appearance_targets(s, p)?;
|
||||
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()))
|
||||
}
|
||||
|
||||
@@ -1032,7 +1106,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["weight", "opacity"], "appearance.setItem")?;
|
||||
check_switches(p, &["visible"], "appearance.setItem")?;
|
||||
let paint = paint_from(s, p)?;
|
||||
let ids = row_targets(s, p)?;
|
||||
let ids = row_targets(s, p, "appearance.setItem")?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad("appearance.setItem", "select objects or give ids"));
|
||||
}
|
||||
@@ -1040,7 +1114,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// When every object comes out as it was (for example, when none has item `index`), the document
|
||||
// and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = items_per_target(&before, &ids, &[idx], aligned(p, "appearance.setItem")?);
|
||||
let of = design_reference(s, p, "appearance.setItem")?;
|
||||
let per = rows_per_target(&before, of, &ids, &[idx], aligned(p, "appearance.setItem")?);
|
||||
super::overprint::edit_counted(s, "Appearance", |d| {
|
||||
let mut edited = Vec::new();
|
||||
for (id, at) in ids.iter().zip(&per) {
|
||||
@@ -1127,7 +1202,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// When no object has an item at any of the indices, the document and its undo history stay as
|
||||
// they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = items_per_target(&before, &ids, &idx, aligned(p, "appearance.removeItem")?);
|
||||
let of = design_reference(s, p, "appearance.removeItem")?;
|
||||
let per = rows_per_target(&before, of, &ids, &idx, aligned(p, "appearance.removeItem")?);
|
||||
super::overprint::edit_counted(s, "Remove Item", |d| {
|
||||
for (id, idx) in ids.iter().zip(&per) {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -1168,12 +1244,19 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut first_at = None;
|
||||
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
|
||||
let mut active_at = None;
|
||||
// Aligned rows of a mixed selection are copied right above themselves in each object.
|
||||
if aligned(p, C)? && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() {
|
||||
// Aligned rows of a mixed selection are copied right above themselves in each object. Rows of a graph design's art,
|
||||
// which is left out, go to `to` in an object whose stack is the same as the art's.
|
||||
let of = design_reference(s, p, C)?;
|
||||
if aligned(p, C)?
|
||||
&& to.is_some()
|
||||
&& (shared_stack(&s.doc()?.doc, &ids).is_some() || (of.is_some() && appearances_differ(&s.doc()?.doc, &with_reference(of, &ids))))
|
||||
{
|
||||
return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows"));
|
||||
}
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = items_per_target(&before, &ids, &idx, aligned(p, C)?);
|
||||
// With `to`, the art's stack is the same as the object's, so its rows are the object's, each of which must exist.
|
||||
let per =
|
||||
if to.is_some() { items_per_target(&before, &ids, &idx, aligned(p, C)?) } else { rows_per_target(&before, of, &ids, &idx, aligned(p, C)?) };
|
||||
s.edit("Duplicate Item", |d, _| {
|
||||
let mut any = false;
|
||||
// Copies that became the topmost visible row of their kind: the series stores their paint even when the object
|
||||
@@ -1280,15 +1363,19 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let aligned = aligned(p, C)?;
|
||||
if p.get("from").and_then(Value::as_str) == Some("contents") {
|
||||
// A graph series group's own Contents row, not its paths, which have none.
|
||||
let ids = edit_targets(s, p)?;
|
||||
let ids = edit_targets(s, p, C)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select objects or give ids"));
|
||||
}
|
||||
// The Contents row's place counts the rows below it, which isn't one place in several different stacks either.
|
||||
if aligned && appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &ids)) {
|
||||
return Err(bad(C, "rows of objects whose appearances differ can't be reordered together"));
|
||||
}
|
||||
return move_contents(s, &ids, to);
|
||||
}
|
||||
let ids = require_targets(s, p, C)?;
|
||||
// Where an aligned row would go in each of several different stacks isn't one place.
|
||||
if aligned && appearances_differ(&s.doc()?.doc, &ids) {
|
||||
if aligned && appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &ids)) {
|
||||
return Err(bad(C, "rows of objects whose appearances differ can't be reordered together"));
|
||||
}
|
||||
if p.get("indices").is_some() {
|
||||
|
||||
@@ -178,6 +178,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[
|
||||
BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails },
|
||||
BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams },
|
||||
BuiltinBrushLibrary { id: "dry-media", name: "Dry Media", make: dry_media },
|
||||
BuiltinBrushLibrary { id: "tech-circuit", name: "Tech Circuit", make: tech_circuit },
|
||||
];
|
||||
|
||||
/// Built-in library `id`, made once.
|
||||
@@ -397,6 +398,59 @@ fn border(name: &str, side: Node, corner: Option<Node>, fit: PatternFit) -> Brus
|
||||
Brush { name: name.into(), kind }
|
||||
}
|
||||
|
||||
/// Tech Circuit: original conductor geometry, drawn as filled paths so widths and chamfers
|
||||
/// survive brush warping. Pattern tiles meet at x = 0 and 24 with identical lead cross-sections.
|
||||
fn tech_circuit() -> BrushLibrary {
|
||||
let pad = |x: f64| {
|
||||
black(polygon(&[
|
||||
(x - 4.0, -2.0),
|
||||
(x - 2.0, -4.0),
|
||||
(x + 2.0, -4.0),
|
||||
(x + 4.0, -2.0),
|
||||
(x + 4.0, 2.0),
|
||||
(x + 2.0, 4.0),
|
||||
(x - 2.0, 4.0),
|
||||
(x - 4.0, 2.0),
|
||||
]))
|
||||
};
|
||||
// 45-degree jogs lift the conductor to a parallel run and bring it back.
|
||||
let trace = group(vec![
|
||||
black(polygon(&[
|
||||
(4.0, -1.0),
|
||||
(23.0, -1.0),
|
||||
(33.0, -11.0),
|
||||
(61.0, -11.0),
|
||||
(71.0, -1.0),
|
||||
(96.0, -1.0),
|
||||
(96.0, 1.0),
|
||||
(70.0, 1.0),
|
||||
(60.0, -9.0),
|
||||
(34.0, -9.0),
|
||||
(24.0, 1.0),
|
||||
(4.0, 1.0),
|
||||
])),
|
||||
pad(4.0),
|
||||
pad(96.0),
|
||||
]);
|
||||
let bus = group([-4.0, 0.0, 4.0].into_iter().map(|y| black(shapes::rectangle(Rect::new(0.0, y - 0.75, 24.0, y + 0.75)))).collect());
|
||||
let terminals = group(vec![
|
||||
// Overlap the ring's rim without filling the test point's hole.
|
||||
black(shapes::rectangle(Rect::new(0.0, -0.6, 15.5, 0.6))),
|
||||
black(shapes::rectangle(Rect::new(18.5, -0.6, 24.0, 0.6))),
|
||||
pad(6.0),
|
||||
ring(Point::new(17.0, 0.0), 3.0, 1.4),
|
||||
]);
|
||||
built(
|
||||
"Tech Circuit",
|
||||
Document::new(100.0, 100.0),
|
||||
vec![
|
||||
art("Circuit Trace", trace, Colorization::Tints),
|
||||
border("Bus Lines", bus, None, PatternFit::Stretch),
|
||||
border("Terminal Nodes", terminals, None, PatternFit::Stretch),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// An invisible rectangle that fixes a tile's width.
|
||||
fn spacer(r: Rect) -> Node {
|
||||
Node::path(NodeId(0), shapes::rectangle(r), Appearance::default())
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Live Paint"],
|
||||
Some("Cmd+Alt+X"),
|
||||
"{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}",
|
||||
"{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with the fill rows that draw (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge with the stroke rows that draw (also a weight above 0) of its source, in order with their settings and with that source's opacity and blending mode (on a path cut into several edges, dashes go on along the path with the lengths set, without the fit of dashes aligned to corners and path ends, arrowheads stay at its own ends, and a width profile or brush stays only on an edge that runs the whole path); a face or edge given no such row has one unpainted row; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}",
|
||||
has_selection_or_ids,
|
||||
lp_make
|
||||
),
|
||||
@@ -414,22 +414,274 @@ fn source_nodes(n: &Node) -> Vec<Arc<Node>> {
|
||||
if is_live_paint(n) { b::sources(n).and_then(|g| g.children().cloned()).unwrap_or_default() } else { vec![Arc::new(n.clone())] }
|
||||
}
|
||||
|
||||
/// Does appearance row `i` draw: it is shown, has paint and an opacity above 0, and a stroke also
|
||||
/// has a weight above 0?
|
||||
fn row_draws(i: &AppearanceItem) -> bool {
|
||||
match i {
|
||||
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none() && f.opacity > 0.0,
|
||||
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.opacity > 0.0 && s.width > 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a source path gives the faces and edges made from it.
|
||||
struct SourceStyle {
|
||||
/// The fill rows that draw, in paint order (none when the path's opacity is 0).
|
||||
fills: Vec<AppearanceItem>,
|
||||
/// The stroke rows that draw, in paint order (none when the path's opacity is 0).
|
||||
strokes: Vec<AppearanceItem>,
|
||||
/// The top stroke's weight, for an edge given no stroke that draws.
|
||||
width: f64,
|
||||
opacity: f32,
|
||||
blend: BlendMode,
|
||||
}
|
||||
|
||||
impl SourceStyle {
|
||||
/// The style of source path `n`: its rows keep their order and settings, gradients fitted to
|
||||
/// its bounds keep that fit, and the strokes' pen pressure, tilt, bearing and rotation stay
|
||||
/// with the path.
|
||||
fn of(n: &Node) -> Self {
|
||||
let mut a = (*n.appearance).clone().without_pressure();
|
||||
if a.has_unplaced_gradient()
|
||||
&& let Some(bounds) = n.geometric_bounds()
|
||||
{
|
||||
a.pin_gradients(bounds);
|
||||
}
|
||||
let width = a.stroke().map_or(1.0, |s| s.width);
|
||||
let (mut fills, mut strokes) = (vec![], vec![]);
|
||||
if n.opacity > 0.0 {
|
||||
for i in a.items.into_iter().filter(row_draws) {
|
||||
if i.is_fill() { fills.push(i) } else { strokes.push(i) }
|
||||
}
|
||||
}
|
||||
Self { fills, strokes, width, opacity: n.opacity, blend: n.blend }
|
||||
}
|
||||
|
||||
/// Give part `n` the rows `rows` of this source with its opacity and blending mode; with no
|
||||
/// rows, `n` keeps the unpainted row it has.
|
||||
fn paint(&self, n: &mut Node, rows: &[AppearanceItem]) {
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
n.appearance = Appearance { items: rows.to_vec(), ..Default::default() }.into();
|
||||
n.opacity = self.opacity;
|
||||
n.blend = self.blend;
|
||||
}
|
||||
}
|
||||
|
||||
/// How far, in points, a place on an edge may be from its source path and still be on it.
|
||||
const ON_SOURCE: f64 = 0.05;
|
||||
|
||||
/// Does stroke row `i` have settings that depend on where along its path a piece of it lies or
|
||||
/// which way it runs: dashes, arrowheads, a width profile, a brush, or a gradient along or across
|
||||
/// the stroke?
|
||||
fn depends_on_place(i: &AppearanceItem) -> bool {
|
||||
matches!(i, AppearanceItem::Stroke(s) if s.dash.is_some() || s.start_arrow.is_some() || s.end_arrow.is_some() || s.profile.is_some() || s.brush.is_some() || s.gradient_mode != vectorcraft_doc::StrokeGradientMode::Within)
|
||||
}
|
||||
|
||||
/// One subpath's segments with the length along it where each starts, then its whole length, and
|
||||
/// a grid of square cells listing the segments whose bounds, grown by [`ON_SOURCE`], reach into
|
||||
/// each cell, so finding the segments near a point does not look at every segment.
|
||||
struct Track {
|
||||
segs: Vec<kurbo::PathSeg>,
|
||||
at: Vec<f64>,
|
||||
closed: bool,
|
||||
cell: f64,
|
||||
grid: HashMap<(i64, i64), Vec<usize>>,
|
||||
/// Segments that reach into more than [`Track::MAX_CELLS`] cells, or that come after the grid
|
||||
/// holds [`Track::CELLS_PER_SEGMENT`] entries per segment, looked at for every point.
|
||||
wide: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
const ACCURACY: f64 = 1e-4;
|
||||
const MAX_CELLS: i64 = 4096;
|
||||
const CELLS_PER_SEGMENT: usize = 16;
|
||||
|
||||
fn new(sp: &SubPath) -> Self {
|
||||
use kurbo::{ParamCurveArclen, Shape as _};
|
||||
let mut bp = kurbo::BezPath::new();
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
let segs: Vec<kurbo::PathSeg> = bp.segments().collect();
|
||||
let mut at = Vec::with_capacity(segs.len() + 1);
|
||||
let mut len = 0.0;
|
||||
for s in &segs {
|
||||
at.push(len);
|
||||
len += s.arclen(Self::ACCURACY);
|
||||
}
|
||||
at.push(len);
|
||||
let boxes: Vec<kurbo::Rect> = segs.iter().map(|s| s.bounding_box().inflate(ON_SOURCE, ON_SOURCE)).collect();
|
||||
// Cells about as wide as an average segment's bounds.
|
||||
let mean = boxes.iter().map(|b| b.width().max(b.height())).sum::<f64>() / boxes.len().max(1) as f64;
|
||||
let cell = if mean.is_finite() { mean.max(4.0 * ON_SOURCE) } else { 4.0 * ON_SOURCE };
|
||||
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
|
||||
let mut wide = vec![];
|
||||
// The grid holds at most this many entries in all.
|
||||
let mut budget = boxes.len().saturating_mul(Self::CELLS_PER_SEGMENT);
|
||||
for (k, b) in boxes.iter().enumerate() {
|
||||
let (x0, y0) = Self::cell_of(Point::new(b.x0, b.y0), cell);
|
||||
let (x1, y1) = Self::cell_of(Point::new(b.x1, b.y1), cell);
|
||||
let cells = x1.saturating_sub(x0).saturating_add(1).saturating_mul(y1.saturating_sub(y0).saturating_add(1));
|
||||
let Some(cells) = usize::try_from(cells).ok().filter(|&c| cells <= Self::MAX_CELLS && c <= budget) else {
|
||||
wide.push(k);
|
||||
continue;
|
||||
};
|
||||
budget -= cells;
|
||||
for x in x0..=x1 {
|
||||
for y in y0..=y1 {
|
||||
grid.entry((x, y)).or_default().push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { segs, at, closed: sp.closed, cell, grid, wide }
|
||||
}
|
||||
|
||||
fn cell_of(p: Point, cell: f64) -> (i64, i64) {
|
||||
((p.x / cell).floor() as i64, (p.y / cell).floor() as i64)
|
||||
}
|
||||
|
||||
fn len(&self) -> f64 {
|
||||
self.at.last().copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// The lengths along the subpath to each of its segments' places nearest `p` that are within
|
||||
/// [`ON_SOURCE`] of it (more than one where the subpath passes `p` again).
|
||||
fn places(&self, p: Point) -> Vec<f64> {
|
||||
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
let near = self.grid.get(&Self::cell_of(p, self.cell)).map(Vec::as_slice).unwrap_or_default();
|
||||
let mut out = vec![];
|
||||
for &k in near.iter().chain(&self.wide) {
|
||||
let Some(s) = self.segs.get(k) else { continue };
|
||||
let n = s.nearest(p, 1e-9);
|
||||
if n.distance_sq <= ON_SOURCE * ON_SOURCE {
|
||||
out.push(self.at.get(k).copied().unwrap_or(0.0) + s.subsegment(0.0..n.t).arclen(Self::ACCURACY));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The signed length from `a` to `b` along the subpath (the shorter way round a closed one).
|
||||
fn step(&self, a: f64, b: f64) -> f64 {
|
||||
let (d, len) = (b - a, self.len());
|
||||
if self.closed && len > 0.0 { d - len * (d / len).round() } else { d }
|
||||
}
|
||||
|
||||
/// Of `places`, the one nearest along the subpath to `target`.
|
||||
fn nearest(&self, places: &[f64], target: f64) -> Option<f64> {
|
||||
places.iter().copied().min_by(|x, y| self.step(*x, target).abs().total_cmp(&self.step(*y, target).abs()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where edge `edge` runs along its source path, whose subpaths' tracks are `tracks`: the length
|
||||
/// along the source subpath to the edge's start, whether the edge starts at that subpath's start and
|
||||
/// ends at its end (an open subpath), and whether it runs the whole subpath. The middle of the
|
||||
/// edge's first segment places it, and its ends are taken where they lie that far along the source,
|
||||
/// so a path that passes a point twice is told apart. An edge that runs against the direction of its
|
||||
/// source is reversed first, and a closed edge as long as its closed source is started at the anchor
|
||||
/// where its source starts (one with no anchor there, or a shorter loop cut from a source that
|
||||
/// crosses itself, does not count as running the whole subpath). `None` when the edge is
|
||||
/// not a single piece found on the source.
|
||||
fn edge_run(tracks: &[Track], edge: &mut PathData) -> Option<(f64, bool, bool, bool)> {
|
||||
use kurbo::{ParamCurve, ParamCurveArclen};
|
||||
let [sp] = edge.subpaths.as_slice() else { return None };
|
||||
if sp.segment_count() == 0 {
|
||||
return None;
|
||||
}
|
||||
let (a, z) = (sp.anchors.first()?.p, if sp.closed { sp.anchors.first()?.p } else { sp.anchors.last()?.p });
|
||||
let first = sp.segment(0);
|
||||
let mid = first.eval(0.5);
|
||||
let half = first.subsegment(0.0..0.5).arclen(Track::ACCURACY);
|
||||
let length = sp.anchor_arc_positions().last().copied().unwrap_or(0.0);
|
||||
let edge_closed = sp.closed;
|
||||
// (how far off the fit is, track, start, end, runs forward) for the subpath that fits best.
|
||||
let mut found: Option<(f64, &Track, f64, f64, bool)> = None;
|
||||
for track in tracks {
|
||||
// The start lies half the first segment before its middle along the source, or after it
|
||||
// when the edge runs the other way: take the place and way that fit best.
|
||||
let starts_at = track.places(a);
|
||||
let mut best: Option<(f64, f64, bool)> = None;
|
||||
for pm in track.places(mid) {
|
||||
for forward in [true, false] {
|
||||
let Some(pa) = track.nearest(&starts_at, if forward { pm - half } else { pm + half }) else { continue };
|
||||
let miss = (if forward { track.step(pa, pm) } else { track.step(pm, pa) } - half).abs();
|
||||
if best.is_none_or(|(m, _, _)| miss < m) {
|
||||
best = Some((miss, pa, forward));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((miss, pa, forward)) = best else { continue };
|
||||
let target = if forward { pa + length } else { pa - length };
|
||||
let Some(pz) = track.nearest(&track.places(z), target) else { continue };
|
||||
let miss = miss + track.step(pz, target).abs();
|
||||
if found.is_none_or(|(m, ..)| miss < m) {
|
||||
found = Some((miss, track, pa, pz, forward));
|
||||
}
|
||||
}
|
||||
let (_, track, pa, pz, forward) = found?;
|
||||
let (from, to) = if forward { (pa, pz) } else { (pz, pa) };
|
||||
if !forward {
|
||||
edge.reverse();
|
||||
}
|
||||
let open = !track.closed;
|
||||
let starts = open && from <= ON_SOURCE;
|
||||
let ends = open && to >= track.len() - ON_SOURCE;
|
||||
if open {
|
||||
return Some((from, starts, ends, starts && ends));
|
||||
}
|
||||
// A closed edge runs the whole closed subpath, from the anchor the planar map starts it at.
|
||||
// Start it at the anchor where the source starts, so settings that follow the path begin there.
|
||||
let start = track.segs.first().map(|s| s.start())?;
|
||||
let moved = edge_closed
|
||||
&& (length - track.len()).abs() <= ON_SOURCE.max(track.len() * 1e-4)
|
||||
&& edge.subpaths.first_mut().is_some_and(|sp| match sp.anchors.iter().position(|a| a.p.distance(start) <= ON_SOURCE) {
|
||||
Some(k) => {
|
||||
sp.anchors.rotate_left(k);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
});
|
||||
Some((if moved { 0.0 } else { from }, false, false, moved))
|
||||
}
|
||||
|
||||
/// Fit stroke row `row` of a source path to an edge cut from it, which runs as [`edge_run`] says
|
||||
/// (`None`: not known): its dashes go on from where the edge starts along the source, with the
|
||||
/// lengths set and without the fit of dashes aligned to corners and path ends, its arrowheads stay only at the source's own ends, and its width profile and brush, which spread
|
||||
/// over the whole path, stay only on an edge that runs the whole path.
|
||||
fn fit_to_edge(row: &mut AppearanceItem, run: Option<(f64, bool, bool, bool)>) {
|
||||
let AppearanceItem::Stroke(s) = row else { return };
|
||||
let (from, starts, ends, whole) = run.unwrap_or((0.0, false, false, false));
|
||||
if !whole {
|
||||
if let Some(d) = &mut s.dash
|
||||
&& run.is_some()
|
||||
{
|
||||
d.offset += from;
|
||||
d.align_corners = false;
|
||||
}
|
||||
s.profile = None;
|
||||
s.brush = None;
|
||||
}
|
||||
if !starts {
|
||||
s.start_arrow = None;
|
||||
}
|
||||
if !ends {
|
||||
s.end_arrow = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The planar map's inputs from `sources` (paths and compound paths, back → front), with the
|
||||
/// fill, stroke and stroke width of each.
|
||||
fn source_shapes(sources: &[Arc<Node>]) -> (Vec<po::Shape>, Vec<(Paint, Paint, f64)>) {
|
||||
/// style of each.
|
||||
fn source_shapes(sources: &[Arc<Node>]) -> (Vec<po::Shape>, Vec<SourceStyle>) {
|
||||
let mut lv = vec![];
|
||||
for n in sources {
|
||||
b::leaf_shapes(n, &mut lv);
|
||||
}
|
||||
let mut shapes = vec![];
|
||||
let mut styles: Vec<(Paint, Paint, f64)> = vec![];
|
||||
let mut styles: Vec<SourceStyle> = vec![];
|
||||
for l in lv {
|
||||
if let Some((path, rule)) = b::node_outline(l)
|
||||
&& !path.is_empty()
|
||||
{
|
||||
shapes.push(po::Shape::new(path, rule, styles.len() as u64));
|
||||
let a = &l.appearance;
|
||||
styles.push((a.fill_paint(), a.stroke_paint(), a.stroke().map(|s| s.width).unwrap_or(1.0)));
|
||||
styles.push(SourceStyle::of(l));
|
||||
}
|
||||
}
|
||||
(shapes, styles)
|
||||
@@ -462,17 +714,35 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>, max_gap: Option<f64
|
||||
let nfaces = regs.len();
|
||||
for r in regs {
|
||||
let id = d.alloc_id();
|
||||
let fill = r.top().and_then(|t| styles.get(t)).map_or(Paint::None, |s| s.0.clone());
|
||||
let mut n = Node::path(id, r.path, fill_only(fill));
|
||||
let mut n = Node::path(id, r.path, fill_only(Paint::None));
|
||||
n.name = Some(FACE_NAME.into());
|
||||
// The front-most covering source with a fill that draws gives the face its fill rows.
|
||||
if let Some(s) = r.sources.iter().rev().filter_map(|&t| styles.get(t)).find(|s| !s.fills.is_empty()) {
|
||||
s.paint(&mut n, &s.fills);
|
||||
}
|
||||
children.push(Arc::new(n));
|
||||
}
|
||||
let nedges = edges.len();
|
||||
// The tracks of the sources whose edges need them, measured once each.
|
||||
let mut tracks: HashMap<usize, Vec<Track>> = HashMap::new();
|
||||
for e in edges {
|
||||
let Some((_, stroke, w)) = styles.get(e.key as usize) else { continue };
|
||||
let key = e.key as usize;
|
||||
let Some(s) = styles.get(key) else { continue };
|
||||
let mut path = e.path;
|
||||
let mut rows = s.strokes.clone();
|
||||
if rows.iter().any(depends_on_place) {
|
||||
let source = tracks
|
||||
.entry(key)
|
||||
.or_insert_with(|| shapes.get(key).map(|src| src.path.subpaths.iter().map(Track::new).collect()).unwrap_or_default());
|
||||
let run = edge_run(source, &mut path);
|
||||
for r in &mut rows {
|
||||
fit_to_edge(r, run);
|
||||
}
|
||||
}
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::path(id, e.path, stroke_only(stroke.clone(), *w));
|
||||
let mut n = Node::path(id, path, stroke_only(Paint::None, s.width));
|
||||
n.name = Some(EDGE_NAME.into());
|
||||
s.paint(&mut n, &rows);
|
||||
children.push(Arc::new(n));
|
||||
}
|
||||
Ok((children, nfaces, nedges))
|
||||
@@ -481,8 +751,9 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>, max_gap: Option<f64
|
||||
/// Give the faces and edges of `g`, a Live Paint group made again from the sources of `old`, the
|
||||
/// look of `old`'s face or edge where they lie: each face takes everything but the outline (its
|
||||
/// appearance, none included, its transparency and the rest) of the old face its inside point is
|
||||
/// in, and each edge that of the old edge its middle lies on. Faces and edges where `old` had none
|
||||
/// keep the paint of their sources.
|
||||
/// in, and each edge that of the old edge its middle lies on, with the old edge's stroke rows fitted
|
||||
/// to the part of it the new edge runs, as [`fit_to_edge`] fits a source's rows. Faces and edges
|
||||
/// where `old` had none keep the paint of their sources.
|
||||
fn repaint_from(g: &mut Node, old: &Node) {
|
||||
/// How far an edge's middle may be from the old edge it lies on.
|
||||
const ON_EDGE: f64 = 1e-3;
|
||||
@@ -505,7 +776,30 @@ fn repaint_from(g: &mut Node, old: &Node) {
|
||||
n.id = c.id;
|
||||
n.kind = c.kind.clone();
|
||||
n.bbox_angle = c.bbox_angle;
|
||||
// An edge cut from the old one takes the old edge's dashes, arrowheads, width profile
|
||||
// and brush as they fall on its part of the old edge.
|
||||
if c.name.as_deref() == Some(EDGE_NAME)
|
||||
&& let Some(old_path) = was.path_data()
|
||||
{
|
||||
fit_to_old_edge(&mut n, old_path);
|
||||
}
|
||||
*c = Arc::new(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit the stroke rows of edge `n`, which it took from an old edge whose outline is `old`, to the
|
||||
/// part of the old edge that `n` runs, as [`fit_to_edge`] fits a source's rows to an edge cut from
|
||||
/// it; `n` is reversed when it runs against the old edge.
|
||||
fn fit_to_old_edge(n: &mut Node, old: &PathData) {
|
||||
if !n.appearance.items.iter().any(depends_on_place) {
|
||||
return;
|
||||
}
|
||||
let tracks: Vec<Track> = old.subpaths.iter().map(Track::new).collect();
|
||||
let run = n.path_data_mut().and_then(|path| edge_run(&tracks, path));
|
||||
let mut a = (*n.appearance).clone();
|
||||
for r in &mut a.items {
|
||||
fit_to_edge(r, run);
|
||||
}
|
||||
n.appearance = a.into();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::{
|
||||
Appearance, AppearanceItem, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId,
|
||||
PathData, Point, Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json,
|
||||
is_live_paint, json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes,
|
||||
switch_or,
|
||||
PathData, Point, Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite,
|
||||
fit_to_old_edge, ids_json, is_live_paint, json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids,
|
||||
source_nodes, source_shapes, switch_or,
|
||||
};
|
||||
|
||||
/// `options` as stored on a Live Paint group: none for the defaults.
|
||||
@@ -85,6 +85,9 @@ pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
|
||||
}
|
||||
|
||||
/// How far, in points, the middle of an edge Merge makes may be from the old edge it lies on.
|
||||
const ON_OLD_EDGE: f64 = 1e-3;
|
||||
|
||||
/// What Merge carries from a face or edge to the part it makes again in its place, another path:
|
||||
/// the appearance, visibility, opacity and blending mode. Gradients fitted to the old outline keep
|
||||
/// that fit, and the strokes' pen pressure, tilt, bearing and rotation stay behind.
|
||||
@@ -125,18 +128,11 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
|
||||
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
|
||||
check_live_paint_takes(d, roots)?;
|
||||
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
|
||||
// Paint to carry over: (point inside face, paint) for faces that draw, and (point on edge,
|
||||
// paint) for every edge.
|
||||
// Paint to carry over: (point inside face, paint) for faces that draw; edges are matched to the
|
||||
// old edges below.
|
||||
let keep_faces: Vec<(Point, Carried)> =
|
||||
b::faces(&old).iter().filter(|f| draws(f)).filter_map(|f| Some((po::interior_point(f.path_data()?)?, Carried::of(f)))).collect();
|
||||
let keep_edges: Vec<(Point, Carried)> = b::edges(&old)
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let pd = e.path_data()?;
|
||||
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
|
||||
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), Carried::of(e)))
|
||||
})
|
||||
.collect();
|
||||
let old_parts = old.clone();
|
||||
let mut sources = source_nodes(&old);
|
||||
for r in roots {
|
||||
if *r != target {
|
||||
@@ -162,12 +158,24 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
|
||||
carried.onto(Arc::make_mut(f));
|
||||
}
|
||||
}
|
||||
for (pt, carried) in keep_edges {
|
||||
if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id)
|
||||
&& let Some(ch) = g.children_mut()
|
||||
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
|
||||
{
|
||||
carried.onto(Arc::make_mut(e));
|
||||
// Each edge whose middle lies on an old edge, every piece of an edge an added path cuts
|
||||
// included, takes the old edge's paint, with its rows fitted to its part of the old edge.
|
||||
if let Some(ch) = g.children_mut() {
|
||||
for c in ch.iter_mut().filter(|c| c.name.as_deref() == Some(EDGE_NAME)) {
|
||||
let Some(mid) = c
|
||||
.path_data()
|
||||
.and_then(|pd| pd.subpaths.first())
|
||||
.filter(|sp| sp.segment_count() > 0)
|
||||
.map(|sp| kurbo::ParamCurve::eval(&sp.segment(0), 0.5))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(was) = edge_near(&old_parts, mid, ON_OLD_EDGE) else { continue };
|
||||
let e = Arc::make_mut(c);
|
||||
Carried::of(was).onto(e);
|
||||
if let Some(outline) = was.path_data() {
|
||||
fit_to_old_edge(e, outline);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut merged = Beside::replacing(d, roots);
|
||||
|
||||
@@ -13,7 +13,9 @@ use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::appearance::{ItemTarget, aligned, appearance_targets, edit_targets, index_param, item_indices, item_target, item_target_at};
|
||||
use super::appearance::{
|
||||
ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, with_reference,
|
||||
};
|
||||
use super::expand::has_seeded;
|
||||
use super::rasterfx;
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or};
|
||||
@@ -49,7 +51,7 @@ fn effect_specs() -> Vec<CommandSpec> {
|
||||
"Apply Effect",
|
||||
[],
|
||||
None,
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}",
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers), aligned?: bool (item is a row of the first target's stack: each other target gets the effect on its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, a row of the art's stack: docs/mcp.md)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
@@ -67,7 +69,7 @@ fn effect_specs() -> Vec<CommandSpec> {
|
||||
"Remove Effect",
|
||||
[],
|
||||
None,
|
||||
"{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), 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)} → {ids}",
|
||||
"{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), 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)} → {ids}",
|
||||
has_doc,
|
||||
remove
|
||||
),
|
||||
@@ -76,7 +78,7 @@ fn effect_specs() -> Vec<CommandSpec> {
|
||||
"Effect Options",
|
||||
[],
|
||||
None,
|
||||
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), 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)} → {ids}",
|
||||
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), 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)} → {ids}",
|
||||
has_doc,
|
||||
set_params
|
||||
),
|
||||
@@ -85,7 +87,7 @@ fn effect_specs() -> Vec<CommandSpec> {
|
||||
"3D Lighting Preset",
|
||||
[],
|
||||
None,
|
||||
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), 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)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}",
|
||||
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), 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)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}",
|
||||
has_doc,
|
||||
lighting_preset
|
||||
),
|
||||
@@ -103,7 +105,7 @@ fn effect_specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Effect",
|
||||
[],
|
||||
None,
|
||||
"{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), 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)} insert a copy of each effect right after it, in one undo step → {ids}",
|
||||
"{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), 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)} insert a copy of each effect right after it, in one undo step → {ids}",
|
||||
has_doc,
|
||||
duplicate
|
||||
),
|
||||
@@ -112,7 +114,7 @@ fn effect_specs() -> Vec<CommandSpec> {
|
||||
"Move Effect",
|
||||
[],
|
||||
None,
|
||||
"{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
|
||||
"{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ, or differ from that of a graph design's art given first)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
|
||||
has_doc,
|
||||
move_effect
|
||||
),
|
||||
@@ -296,14 +298,20 @@ fn ids_json(ids: &[NodeId]) -> Value {
|
||||
/// effects it edits (`None`: the object's own) and the effect's index there. With `aligned` (see
|
||||
/// [`ItemTarget::per_target`]) `item` and `index` are the first target's, and each other target
|
||||
/// edits its equal item, or its equal object-level effect, each target matched on its own whatever
|
||||
/// the other targets have; in a target without them, `item` and `index` stand for themselves.
|
||||
/// the other targets have; in a target without them, `item` and `index` stand for themselves. When the first object
|
||||
/// given is the art of a graph design in a series ([`design_reference`]), which is left out, they are rows of its stack,
|
||||
/// and a target without them is left out.
|
||||
pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result<Vec<(NodeId, Option<usize>, usize)>> {
|
||||
let roots = edit_targets(s, p)?;
|
||||
let roots = edit_targets(s, p, cmd)?;
|
||||
let of = reference_of(s, p, item, cmd)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let per = item.per_target(s, &roots);
|
||||
let first = roots.first().and_then(|f| d.node(*f));
|
||||
let per = item.per_target_of(s, of, &roots);
|
||||
let first = of.or_else(|| roots.first().copied()).and_then(|f| d.node(f));
|
||||
// Whether `item` names an item in the stack it is a row of (else the edit is object-level there).
|
||||
let anchor = roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a));
|
||||
let anchor = match of {
|
||||
Some(of) => d.node(of),
|
||||
None => roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a)),
|
||||
};
|
||||
let anchor_level = anchor.map(|a| item.effects_item(&a.appearance, cmd)).transpose()?.flatten();
|
||||
let aligned = aligned(p, cmd)?;
|
||||
let mut out = Vec::with_capacity(roots.len());
|
||||
@@ -312,7 +320,11 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us
|
||||
let at = item.effects_item(&n.appearance, cmd)?;
|
||||
// An object-level effect this target shares with the first is where it is in this target.
|
||||
let index = match (first.filter(|_| aligned), at, anchor_level) {
|
||||
(Some(f), None, None) => SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1).unwrap_or(index),
|
||||
(Some(f), None, None) => match SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1) {
|
||||
Some(i) => i,
|
||||
None if of.is_some() && index != APPENDED => continue,
|
||||
None => index,
|
||||
},
|
||||
_ => index,
|
||||
};
|
||||
out.push((*id, at, index));
|
||||
@@ -320,6 +332,19 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The effect index `effect.apply` gives [`effect_targets`] for the effect it appends, which names no effect the targets
|
||||
/// 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,
|
||||
@@ -362,6 +387,20 @@ fn edit_effects_at(
|
||||
indices: &[usize],
|
||||
mut f: impl FnMut(&mut Vec<Effect>, &[usize]) -> bool,
|
||||
) -> Result<Vec<NodeId>> {
|
||||
// With a graph design's art given first, the indices are effects of its list: one it doesn't have names no effect,
|
||||
// and the edit is refused as a whole, as it is in a list that lacks one of them.
|
||||
if let Some(of) = reference_of(s, p, item, cmd)?
|
||||
&& let Some(n) = s.doc()?.doc.node(of)
|
||||
{
|
||||
let ap = &n.appearance;
|
||||
let len = match item.effects_item(ap, cmd)? {
|
||||
None => ap.effects.len(),
|
||||
Some(i) => ap.items.get(i).map_or(0, |it| it.effects().len()),
|
||||
};
|
||||
if indices.iter().any(|i| *i >= len) {
|
||||
return Err(EngineError::Other(none.into()));
|
||||
}
|
||||
}
|
||||
// Each target's list and the positions in it, in the order the targets first come up.
|
||||
let mut targets: Vec<(NodeId, Option<usize>, Vec<usize>)> = vec![];
|
||||
for &index in indices {
|
||||
@@ -453,13 +492,18 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
|
||||
}
|
||||
let item = item_target(s, p, C)?;
|
||||
// The item as each target has it, read before the effect changes their rows: a row of a graph design's art, which is
|
||||
// left out of the targets, is the row equal to it there, and a target without one is left out.
|
||||
let targets = edit_targets(s, p, C)?;
|
||||
let of = reference_of(s, p, item, C)?;
|
||||
let per: Vec<(NodeId, Option<ItemTarget>)> = targets.iter().copied().zip(item.per_target_of(s, of, &targets)).collect();
|
||||
if effects::is_three_d(id) {
|
||||
let targets = edit_targets(s, p)?;
|
||||
for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) {
|
||||
for &(target, at) in &per {
|
||||
let Some(at) = at else { continue };
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(target).ok_or(EngineError::NoNode(target))?;
|
||||
// A new effect goes after the item's (or the object's) others.
|
||||
let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten();
|
||||
let on = at.effects_item(&n.appearance, C).ok().flatten();
|
||||
let on = on.and_then(|i| Some((Some(i), n.appearance.items.get(i)?.effects().len())));
|
||||
let on = on.unwrap_or((None, n.appearance.effects.len()));
|
||||
effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?;
|
||||
@@ -474,14 +518,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", 0, |fx, _| {
|
||||
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", APPENDED, |fx, _| {
|
||||
fx.push(effect.clone());
|
||||
true
|
||||
})?;
|
||||
// The item the effect landed on in the object whose row the item is (the first selected object
|
||||
// for the active item, which applies where it fits), and the effect's index in that object.
|
||||
let first = ids.get(item.anchor(s, &ids)).and_then(|id| s.doc().ok()?.doc.node(*id));
|
||||
let landed = first.and_then(|n| item.effects_item(&n.appearance, C).ok().flatten());
|
||||
let k = item.anchor(s, &ids);
|
||||
let first = ids.get(k).and_then(|id| s.doc().ok()?.doc.node(*id));
|
||||
let at = match of {
|
||||
Some(_) => ids.get(k).and_then(|id| per.iter().find(|(t, _)| t == id)).and_then(|(_, at)| *at).unwrap_or(item),
|
||||
None => item,
|
||||
};
|
||||
let landed = first.and_then(|n| at.effects_item(&n.appearance, C).ok().flatten());
|
||||
let index = first.and_then(|n| n.appearance.effects_at(landed)).map_or(0, |fx| fx.len().saturating_sub(1));
|
||||
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed }))
|
||||
}
|
||||
@@ -495,7 +544,7 @@ fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result<Option<Vec<N
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
// The art of a graph design in a series stays where the graph draws it.
|
||||
let roots = super::appearance::without_design_art(d, super::appearance::subject_roots(s)?, "appearance")?;
|
||||
let roots = super::appearance::without_design_art(d, super::appearance::subject_roots(s)?, "effect.apply")?;
|
||||
let grouped = roots.iter().any(|r| d.node(*r).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })));
|
||||
Ok((roots.len() >= 2 && !grouped).then_some(roots))
|
||||
}
|
||||
@@ -608,9 +657,9 @@ fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src = item_target_at(s, p, "fromItem", C)?;
|
||||
let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src };
|
||||
let copy = switch_or(p, "copy", false, C)?;
|
||||
let roots = edit_targets(s, p)?;
|
||||
let roots = edit_targets(s, p, C)?;
|
||||
// Where an aligned effect would go in each of several different stacks isn't one place.
|
||||
if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &roots) {
|
||||
if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &roots)) {
|
||||
return Err(bad(C, "effects of objects whose appearances differ can't be moved together"));
|
||||
}
|
||||
// Where the effect landed in each object it moved in, in the order of `ids`.
|
||||
@@ -835,11 +884,13 @@ pub(crate) fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const GLASS: &str = "distort.glass";
|
||||
/// Effect › Texture › Texturizer.
|
||||
const TEXTURIZER: &str = "texture.texturizer";
|
||||
/// Effect › Artistic › Rough Pastels.
|
||||
const ROUGH_PASTELS: &str = "artistic.roughPastels";
|
||||
|
||||
/// Does effect `id` have Load Texture: a `textureImage` that `effect.apply` reads from an image
|
||||
/// file (Glass, Texturizer)?
|
||||
/// file (Glass, Texturizer, Rough Pastels)?
|
||||
pub(crate) fn texture_load_effect(id: &str) -> bool {
|
||||
id == GLASS || id == TEXTURIZER
|
||||
id == GLASS || id == TEXTURIZER || id == ROUGH_PASTELS
|
||||
}
|
||||
|
||||
/// Glass's `textureImage` in `params` as Glass stores it: an image file (`{path}`, or `{name?,
|
||||
@@ -896,8 +947,11 @@ fn can_expand(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?;
|
||||
let d = &s.doc().map_err(|e| e.to_string())?.doc;
|
||||
// The art of a graph design in a series, selected or inside a selected graph, is drawn again from the design.
|
||||
let ids = super::appearance::drop_design_art(d, ids);
|
||||
// A dimension selected with the art it measures is left as it is.
|
||||
let kept = super::dimension::with_their_art(d, &ids);
|
||||
let mut kept = super::dimension::with_their_art(d, &ids);
|
||||
kept.extend(super::appearance::design_art_in(d, &ids));
|
||||
if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -1223,12 +1277,22 @@ fn holds_plugin_effect(d: &Document, n: &Node, in_art: bool, seen: &mut std::col
|
||||
}
|
||||
|
||||
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = appearance_targets(s, p)?;
|
||||
let roots = edit_targets(s, p, "effect.expandAppearance")?;
|
||||
// In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the
|
||||
// geometry it hasn't changed.
|
||||
if vectorcraft_plugins::safe_mode::active() {
|
||||
// A dimension selected with the art it measures is left as it is.
|
||||
let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots);
|
||||
// A dimension selected with the art it measures is left as it is, and so is the art of a graph design in a
|
||||
// series.
|
||||
let mut d = super::dimension::without_kept(&s.doc()?.doc, &roots);
|
||||
let designs = super::appearance::design_art_in(&d, &roots);
|
||||
if !designs.is_empty() {
|
||||
let view = d.to_mut();
|
||||
for id in designs {
|
||||
// Art already gone (inside other art removed) needs nothing more.
|
||||
let _ = view.remove(id);
|
||||
}
|
||||
}
|
||||
let d = &*d;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) {
|
||||
return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into()));
|
||||
@@ -1238,6 +1302,10 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let ids = s.edit("Expand Appearance", |d, _| {
|
||||
let before = d.clone();
|
||||
// The art of graph designs in the graphs and series among the roots stays as the graph draws it. A group whose
|
||||
// own effect turns it into other art as a whole (a blur, feather or inner glow makes it an image) takes its
|
||||
// art into that art, which draws it, so that art has no place to go back to.
|
||||
let designs = super::appearance::design_art_in(d, &roots);
|
||||
// A dimension selected with the art it measures keeps its own lines and label: it is out
|
||||
// of the document while the art expands.
|
||||
let lifted = super::dimension::lift(d, &roots);
|
||||
@@ -1249,6 +1317,15 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &roots {
|
||||
expand_node(d, *id, &brushes, &mut out);
|
||||
}
|
||||
for id in &designs {
|
||||
if let (Some(was), Some(slot)) = (before.node(*id), d.node_mut(*id)) {
|
||||
*slot = was.clone();
|
||||
}
|
||||
}
|
||||
if !designs.is_empty() {
|
||||
// A mark that expands into a group stays listed: only what lies in the restored art is left out.
|
||||
out.retain(|id| d.node(*id).is_some() && !d.ancestry(*id).is_some_and(|up| up.iter().any(|a| designs.contains(a))));
|
||||
}
|
||||
super::dimension::put_back(d, lifted);
|
||||
// A dimension whose source was replaced stays where it was, no longer linked.
|
||||
super::dimension::restore_unchanged(&before, d, ended);
|
||||
|
||||
@@ -95,7 +95,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
effect_targets(s, p, item, index, C)?
|
||||
} else {
|
||||
let mut out = vec![];
|
||||
for id in edit_targets(s, p)? {
|
||||
for id in edit_targets(s, p, C)? {
|
||||
let (item, index) = d.node(id).and_then(first_three_d).ok_or_else(|| bad(C, format!("object {} needs a 3D effect", id.0)))?;
|
||||
out.push((id, item, index));
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ pub mod jxlread;
|
||||
mod legacy_ai;
|
||||
mod load;
|
||||
mod mesh3d;
|
||||
pub(crate) use load::{MAX_RASTER_ALLOC, size_note};
|
||||
pub(crate) use load::{MAX_RASTER_ALLOC, file_clipped, size_note};
|
||||
mod heifread;
|
||||
mod hpgl;
|
||||
mod jp2read;
|
||||
|
||||
@@ -1074,16 +1074,32 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::scale_non_uniform(sx, sy);
|
||||
let mut n = Node::new(
|
||||
let n = Node::new(
|
||||
id,
|
||||
NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default(), pixels: None })),
|
||||
);
|
||||
let mut n = file_clipped(&mut d, n, psdread::clipping_path(bytes));
|
||||
n.name = Some(name.to_string());
|
||||
d.images.insert(key, blob);
|
||||
d.insert(Some(layer), 0, n).map_err(err)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Image `image` of `d` clipped by `clip`, the clipping path its file names in the image's pixel
|
||||
/// space (a PSD or PSB file's, [`psdread::clipping_path`]): a clip group of that path (even-odd,
|
||||
/// with no paint) and the image. Without one the image stays as it is.
|
||||
pub(crate) fn file_clipped(d: &mut Document, image: Node, clip: Option<vectorcraft_geom::PathData>) -> Node {
|
||||
let (Some(mut path), NodeKind::Image(im)) = (clip, &image.kind) else { return image };
|
||||
path.transform(im.xf);
|
||||
let none = vectorcraft_color::Paint::None;
|
||||
let mut clip = Node::path(d.alloc_id(), path, vectorcraft_doc::Appearance::basic(none.clone(), none, 0.0));
|
||||
if let NodeKind::Path { clipping, rule, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
*rule = vectorcraft_geom::FillRule::EvenOdd;
|
||||
}
|
||||
Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), std::sync::Arc::new(image)], clip: true })
|
||||
}
|
||||
|
||||
/// Does this import note say that the file has something the document doesn't?
|
||||
fn is_loss(note: &str) -> bool {
|
||||
note == vectorcraft_pdf::OFF_ARTBOARD_NOTE || vectorcraft_eps::is_loss(note)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! File › Place (#918): the merged image, the flattened picture every PSD carries after its layers,
|
||||
//! as a linked PSD shows. Written from Adobe's public "Photoshop File Formats Specification"
|
||||
//! (<https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/>). File › Open reads the
|
||||
//! layers with [`super::psdlayers`], which shares this reader.
|
||||
//! layers with [`super::psdlayers`], which shares this reader. The clipping path a document names
|
||||
//! ([`clipping_path`]) clips its merged image where it opens or is placed.
|
||||
//!
|
||||
//! Every mode Photoshop saves a merged image in is read: Bitmap, Grayscale, Duotone (as its
|
||||
//! grayscale data), Indexed, RGB, CMYK and Lab, at 1, 8, 16 and 32 bits per channel, stored raw,
|
||||
@@ -14,6 +15,7 @@
|
||||
use std::io::Read;
|
||||
|
||||
use vectorcraft_color::cms::{Lab, lab::lab_to_srgb};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath};
|
||||
|
||||
/// Photoshop's colour modes (the header's numbers), as far as the merged image is concerned.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -154,30 +156,113 @@ pub(super) fn header<'a>(r: &mut Reader<'a>) -> Result<Header<'a>, String> {
|
||||
pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let h = header(&mut r).ok()?;
|
||||
let mut r = Reader::new(h.resources);
|
||||
while !r.rest().is_empty() {
|
||||
r.take(4).ok()?;
|
||||
let (_, _, data) = resources(h.resources, false).find(|(id, _, _)| *id == 0x03ed)?;
|
||||
let mut d = Reader::new(data);
|
||||
// Fixed 16.16 values, each with its unit (1: per inch, 2: per centimetre) and the unit the
|
||||
// size is shown in.
|
||||
let mut axis = || -> Option<f64> {
|
||||
let v = f64::from(d.u32().ok()?) / 65536.0;
|
||||
let unit = d.u16().ok()?;
|
||||
d.u16().ok()?;
|
||||
Some(if unit == 2 { v * 2.54 } else { v })
|
||||
};
|
||||
Some((axis()?, axis()?))
|
||||
}
|
||||
|
||||
/// The image resource blocks of `resources`, in file order: each one's id, name (the bytes of its
|
||||
/// Pascal string) and data. Stops at the first block that is cut short. With `signed`, only the
|
||||
/// blocks whose signature is `8BIM` are given.
|
||||
fn resources(resources: &[u8], signed: bool) -> impl Iterator<Item = (u16, &[u8], &[u8])> {
|
||||
let mut r = Reader::new(resources);
|
||||
std::iter::from_fn(move || {
|
||||
if r.rest().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ours = r.take(4).ok()? == b"8BIM";
|
||||
let id = r.u16().ok()?;
|
||||
// A Pascal string padded to an even size, length byte included.
|
||||
let name = usize::from(r.u8().ok()?);
|
||||
r.take(name + (name + 1) % 2).ok()?;
|
||||
let len = usize::from(r.u8().ok()?);
|
||||
let name = r.take(len).ok()?;
|
||||
r.take((len + 1) % 2).ok()?;
|
||||
let size = usize::try_from(r.u32().ok()?).ok()?;
|
||||
let data = r.take(size).ok()?;
|
||||
r.take(size % 2).ok()?;
|
||||
if id == 0x03ed {
|
||||
let mut d = Reader::new(data);
|
||||
// Fixed 16.16 values, each with its unit (1: per inch, 2: per centimetre) and the
|
||||
// unit the size is shown in.
|
||||
let mut axis = || -> Option<f64> {
|
||||
let v = f64::from(d.u32().ok()?) / 65536.0;
|
||||
let unit = d.u16().ok()?;
|
||||
d.u16().ok()?;
|
||||
Some(if unit == 2 { v * 2.54 } else { v })
|
||||
};
|
||||
return Some((axis()?, axis()?));
|
||||
Some((ours, id, name, data))
|
||||
})
|
||||
.filter(move |(ours, ..)| *ours || !signed)
|
||||
.map(|(_, id, name, data)| (id, name, data))
|
||||
}
|
||||
|
||||
/// The resource naming the document's clipping path.
|
||||
const CLIPPING_PATH_NAME: u16 = 2999;
|
||||
|
||||
/// The saved paths' resource ids.
|
||||
const SAVED_PATHS: std::ops::RangeInclusive<u16> = 2000..=2997;
|
||||
|
||||
/// The clipping path a PSD or PSB file names, in its pixel space (x across its width, y down
|
||||
/// its height): the saved path (resources 2000 to 2997) whose name the clipping path resource
|
||||
/// (2999) gives, both `8BIM` resources. `None` when the document names none, names a path it doesn't have, or that path
|
||||
/// has no anchors or more records than [`vectorcraft_geom::path::MAX_READ_ANCHORS`]. Saved paths
|
||||
/// use the even-odd rule. A path whose initial fill record says the fill starts with all pixels
|
||||
/// begins with a subpath around the whole image, so its subpaths cut holes in it.
|
||||
pub fn clipping_path(bytes: &[u8]) -> Option<PathData> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let h = header(&mut r).ok()?;
|
||||
let (_, _, named) = resources(h.resources, true).find(|(id, _, _)| *id == CLIPPING_PATH_NAME)?;
|
||||
// A Pascal string, then the flatness and a fill rule, which the specification says is ignored.
|
||||
let len = usize::from(*named.first()?);
|
||||
let name = named.get(1..1 + len)?;
|
||||
let (_, _, data) = resources(h.resources, true).find(|(id, n, _)| SAVED_PATHS.contains(id) && *n == name)?;
|
||||
// A path of more records than a document may hold anchors is left out before it is built.
|
||||
if data.len() / PATH_RECORD > vectorcraft_geom::path::MAX_READ_ANCHORS {
|
||||
return None;
|
||||
}
|
||||
let path = saved_path(data, f64::from(h.width), f64::from(h.height));
|
||||
(!path.is_empty()).then_some(path)
|
||||
}
|
||||
|
||||
/// One record of a saved path: a selector, then 24 bytes.
|
||||
const PATH_RECORD: usize = 26;
|
||||
|
||||
/// The saved path of path records `data`, scaled to an image `width` × `height` pixels. Knot
|
||||
/// records come after their subpath's length record; one that comes before any, or past the knots
|
||||
/// a length record counts, is left out, as is a last record cut short.
|
||||
fn saved_path(data: &[u8], width: f64, height: f64) -> PathData {
|
||||
// A path point: a vertical, then a horizontal signed 8.24 fixed-point fraction of the image's
|
||||
// height and width.
|
||||
let fixed = |b: [u8; 4]| f64::from(i32::from_be_bytes(b)) / f64::from(1 << 24);
|
||||
let point = |[v0, v1, v2, v3, h0, h1, h2, h3]: [u8; 8]| Point::new(fixed([h0, h1, h2, h3]) * width, fixed([v0, v1, v2, v3]) * height);
|
||||
let mut subpaths: Vec<SubPath> = vec![];
|
||||
let mut all_pixels = false;
|
||||
// The knots the current subpath still takes.
|
||||
let mut left = 0usize;
|
||||
let (records, _) = data.as_chunks::<PATH_RECORD>();
|
||||
for &[s0, s1, ref body @ ..] in records {
|
||||
let [c0, c1, ..] = *body;
|
||||
let (points, _) = body.as_chunks::<8>();
|
||||
let [a, b, c] = [0, 1, 2].map(|i| points.get(i).copied().map_or(Point::ZERO, point));
|
||||
match u16::from_be_bytes([s0, s1]) {
|
||||
sel @ (0 | 3) => {
|
||||
left = usize::from(u16::from_be_bytes([c0, c1]));
|
||||
// Anchors are added as knots come: a count can claim more than follow.
|
||||
subpaths.push(SubPath::new(vec![], sel == 0));
|
||||
}
|
||||
sel @ (1 | 2 | 4 | 5) if left > 0 => {
|
||||
let Some(sp) = subpaths.last_mut() else { continue };
|
||||
left -= 1;
|
||||
let kind = if matches!(sel, 1 | 4) { AnchorKind::Smooth } else { AnchorKind::Corner };
|
||||
sp.anchors.push(Anchor { h_in: a, p: b, h_out: c, kind });
|
||||
}
|
||||
8 => all_pixels = [c0, c1] == [0, 1],
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
subpaths.retain(|s| !s.anchors.is_empty());
|
||||
if all_pixels && !subpaths.is_empty() {
|
||||
let all = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, width, height));
|
||||
subpaths.splice(0..0, all.subpaths);
|
||||
}
|
||||
PathData::new(subpaths)
|
||||
}
|
||||
|
||||
/// Does the merged image keep its transparency in the first extra channel? Yes when the layer
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Reading Photoshop documents (#918): the merged image of every colour mode, depth and
|
||||
//! compression, PSB files, transparency, the resolution, and damaged files refused without a panic.
|
||||
//! compression, PSB files, transparency, the resolution, the clipping path, and damaged files
|
||||
//! refused without a panic.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, NodeId, NodeKind};
|
||||
|
||||
use super::psdread::{decode, is_psd, resolution};
|
||||
use super::psdread::{clipping_path, decode, is_psd, resolution};
|
||||
use super::*;
|
||||
|
||||
/// A Photoshop document built from its parts: `version` 1 (PSD) or 2 (PSB), the header's fields,
|
||||
@@ -260,6 +262,199 @@ fn pdd_files_open_and_place() {
|
||||
assert!(format_for_name("x.pdd").is_none() && writable_format(None, Some("x.pdd")).is_err(), "an export to .pdd is refused as before");
|
||||
}
|
||||
|
||||
/// An image resource block: `8BIM`, `id`, `name` as a Pascal string padded to an even size, then
|
||||
/// `data` padded to an even size.
|
||||
fn resource(id: u16, name: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut r = b"8BIM".to_vec();
|
||||
r.extend(id.to_be_bytes());
|
||||
r.push(name.len() as u8);
|
||||
r.extend(name);
|
||||
if name.len().is_multiple_of(2) {
|
||||
r.push(0);
|
||||
}
|
||||
r.extend((data.len() as u32).to_be_bytes());
|
||||
r.extend(data);
|
||||
if !data.len().is_multiple_of(2) {
|
||||
r.push(0);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A path point record's 8 bytes: `[x, y]` as fractions of the image's width and height, stored
|
||||
/// vertical first as signed 8.24 fixed-point numbers.
|
||||
fn point([x, y]: [f64; 2]) -> Vec<u8> {
|
||||
[y, x].iter().flat_map(|v| ((v * f64::from(1 << 24)).round() as i32).to_be_bytes()).collect()
|
||||
}
|
||||
|
||||
/// A saved path's records: the fill rule record, an initial fill record saying whether the fill
|
||||
/// starts with all pixels, then each subpath (closed or not) of corner knots without handles.
|
||||
fn saved_path(all_pixels: bool, subpaths: &[(bool, &[[f64; 2]])]) -> Vec<u8> {
|
||||
let record = |selector: u16, body: Vec<u8>| {
|
||||
let mut r = selector.to_be_bytes().to_vec();
|
||||
r.extend(body);
|
||||
r.resize(26, 0);
|
||||
r
|
||||
};
|
||||
let mut d = record(6, vec![]);
|
||||
d.extend(record(8, vec![0, u8::from(all_pixels)]));
|
||||
for (closed, knots) in subpaths {
|
||||
d.extend(record(if *closed { 0 } else { 3 }, (knots.len() as u16).to_be_bytes().to_vec()));
|
||||
for k in *knots {
|
||||
d.extend(record(if *closed { 2 } else { 5 }, [point(*k), point(*k), point(*k)].concat()));
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The clipping path resource (2999) naming the saved path `name`, with a flatness of 0 and the
|
||||
/// fill rule left as it is.
|
||||
fn clipping_name(name: &[u8]) -> Vec<u8> {
|
||||
let mut d = vec![name.len() as u8];
|
||||
d.extend(name);
|
||||
d.extend([0; 6]);
|
||||
resource(2999, b"", &d)
|
||||
}
|
||||
|
||||
/// A 100 × 50 pixel red RGB document with `resources`, flat or with a layer count of 0.
|
||||
fn red_psd(resources: &[u8]) -> Vec<u8> {
|
||||
psd(1, 3, (100, 50), 8, 3, &[], resources, &[], &raw(&[&[255; 5000], &[0; 5000], &[0; 5000]]))
|
||||
}
|
||||
|
||||
/// The triangle saved as "Outline", another path saved as "Other", and the clipping path naming
|
||||
/// "Outline".
|
||||
fn triangle_resources() -> Vec<u8> {
|
||||
let other = saved_path(false, &[(true, &[[0.0, 0.0], [0.1, 0.0], [0.1, 0.1]])]);
|
||||
let outline = saved_path(false, &[(true, &[[0.5, 0.0], [1.0, 1.0], [0.0, 1.0]])]);
|
||||
[resource(2000, b"Other", &other), resource(2001, b"Outline", &outline), clipping_name(b"Outline")].concat()
|
||||
}
|
||||
|
||||
/// The clip group a PSD file comes in as: its clipping path's anchors (pt), whether that path
|
||||
/// uses the even-odd rule, and the image it clips.
|
||||
fn clip_group(d: &Document, id: NodeId) -> (Vec<[f64; 2]>, bool, NodeId) {
|
||||
let Some(NodeKind::Group { children, clip: true }) = d.node(id).map(|n| &n.kind) else { panic!("not a clip group: {:?}", d.node(id)) };
|
||||
let [path, image] = &children[..] else { panic!("{} children", children.len()) };
|
||||
let NodeKind::Path { path: p, rule, clipping: true, .. } = &path.kind else { panic!("not a clipping path") };
|
||||
assert!(matches!(image.kind, NodeKind::Image(_)));
|
||||
let anchors = p.anchors().map(|(_, _, a)| [a.p.x, a.p.y]).collect();
|
||||
(anchors, *rule == vectorcraft_geom::FillRule::EvenOdd, image.id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clipping_path_clips_the_merged_image_on_place_and_open() {
|
||||
let f = red_psd(&triangle_resources());
|
||||
let triangle = vec![[50.0, 0.0], [100.0, 50.0], [0.0, 50.0]];
|
||||
let data = vectorcraft_format::base64_encode(&f);
|
||||
// Place: one clip group of the named path, not the other, at 100 % (72 ppi: a pixel a point).
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": data, "at": [50, 25]})).unwrap();
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let (anchors, even_odd, image) = clip_group(&s.doc().unwrap().doc, id);
|
||||
assert_eq!((anchors, even_odd), (triangle.clone(), true));
|
||||
assert_eq!(r["image"], image.0, "{r}");
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(100.0), Some(50.0)));
|
||||
// Open with layers off, and a flat file opened with layers on, as its merged image.
|
||||
for params in [json!({"layers": false}), json!({})] {
|
||||
s.execute("document.open", &merge(json!({"name": "cut.psd", "dataBase64": data}), params.clone())).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let top = d.layers[0].children().unwrap();
|
||||
assert_eq!(top.len(), 1, "{params}");
|
||||
assert_eq!(top[0].name.as_deref(), Some("cut.psd"), "{params}");
|
||||
let group = top[0].id;
|
||||
let (anchors, even_odd, _) = clip_group(d, group);
|
||||
assert_eq!((anchors, even_odd), (triangle.clone(), true), "{params}");
|
||||
// The corner outside the triangle draws nothing; its middle draws red.
|
||||
let png = image::load_from_memory(&exported(&mut s, json!({"format": "png"}))).unwrap().to_rgba8();
|
||||
assert_eq!((png.get_pixel(2, 2).0[3], png.get_pixel(50, 40).0), (0, [255, 0, 0, 255]), "{params}");
|
||||
// The clipping path is an ordinary path: releasing the clipping mask leaves the image whole.
|
||||
s.execute("select.set", &json!({"ids": [group.0]})).unwrap();
|
||||
s.execute("object.clippingMask.release", &json!({})).unwrap();
|
||||
let png = image::load_from_memory(&exported(&mut s, json!({"format": "png"}))).unwrap().to_rgba8();
|
||||
assert_eq!(png.get_pixel(2, 2).0, [255, 0, 0, 255], "{params}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace keeps a clipped PSD file's scale, as it keeps an image's.
|
||||
#[test]
|
||||
fn replacing_a_clipped_psd_keeps_its_scale() {
|
||||
let data = vectorcraft_format::base64_encode(&red_psd(&triangle_resources()));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": data, "at": [200, 200]})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": data, "replace": true})).unwrap();
|
||||
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.
|
||||
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}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_that_starts_with_all_pixels_puts_the_image_around_the_path() {
|
||||
let hole = saved_path(true, &[(true, &[[0.25, 0.25], [0.75, 0.25], [0.75, 0.75], [0.25, 0.75]])]);
|
||||
let f = red_psd(&[resource(2000, b"Hole", &hole), clipping_name(b"Hole")].concat());
|
||||
let path = clipping_path(&f).unwrap();
|
||||
let starts: Vec<_> = path.subpaths.iter().map(|s| (s.anchors[0].p.x, s.anchors[0].p.y, s.closed)).collect();
|
||||
assert_eq!(starts, [(0.0, 0.0, true), (25.0, 12.5, true)], "the image's edges, then the hole");
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "hole.psd", "dataBase64": vectorcraft_format::base64_encode(&f), "layers": false})).unwrap();
|
||||
let png = image::load_from_memory(&exported(&mut s, json!({"format": "png"}))).unwrap().to_rgba8();
|
||||
assert_eq!((png.get_pixel(2, 2).0, png.get_pixel(50, 25).0[3]), ([255, 0, 0, 255], 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_naming_no_clipping_path_or_a_missing_one_place_a_plain_image() {
|
||||
let other = saved_path(false, &[(true, &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]])]);
|
||||
let empty = saved_path(false, &[]);
|
||||
for (resources, what) in [
|
||||
(resource(2000, b"Other", &other), "saved paths, none named"),
|
||||
([resource(2000, b"Other", &other), clipping_name(b"Gone")].concat(), "a name no path has"),
|
||||
([resource(2000, b"Empty", &empty), clipping_name(b"Empty")].concat(), "a path with no knots"),
|
||||
([resource(3000, b"Other", &other), clipping_name(b"Other")].concat(), "a resource outside the saved paths"),
|
||||
([resource(2000, b"Other", &other), [&b"MeSa"[..], &clipping_name(b"Other")[4..]].concat()].concat(), "a block not signed 8BIM"),
|
||||
(vec![], "no resources"),
|
||||
] {
|
||||
let f = red_psd(&resources);
|
||||
assert!(clipping_path(&f).is_none(), "{what}");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "a.psd", "dataBase64": vectorcraft_format::base64_encode(&f)})).unwrap();
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
assert!(matches!(s.doc().unwrap().doc.node(id).map(|n| &n.kind), Some(NodeKind::Image(_))), "{what}");
|
||||
assert!(r.get("image").is_none(), "{what}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_clipping_paths_are_read_without_a_panic() {
|
||||
let good = red_psd(&triangle_resources());
|
||||
assert!(clipping_path(&good).is_some());
|
||||
// The header, the empty colour mode data, then the resources' length and the resources.
|
||||
let start = 26 + 4;
|
||||
let end = start + 4 + triangle_resources().len();
|
||||
for n in 0..end + 2 {
|
||||
let _ = clipping_path(&good[..n]);
|
||||
}
|
||||
for i in start..end {
|
||||
for v in [0, 1, 0x7f, 0x80, 0xff] {
|
||||
let mut f = good.clone();
|
||||
f[i] = v;
|
||||
let _ = clipping_path(&f);
|
||||
}
|
||||
}
|
||||
// Knots before any length record, and more knots than a length record counts, are left out.
|
||||
let mut extra = saved_path(false, &[(true, &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]])]);
|
||||
extra[26 * 2 + 3] = 2;
|
||||
let mut stray = 2u16.to_be_bytes().to_vec();
|
||||
stray.resize(26, 0);
|
||||
extra.splice(26..26, stray);
|
||||
let path = clipping_path(&red_psd(&[resource(2000, b"P", &extra), clipping_name(b"P")].concat())).unwrap();
|
||||
assert_eq!(path.subpaths.iter().map(|s| s.anchors.len()).collect::<Vec<_>>(), [2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_and_unsupported_files_are_refused_without_a_panic() {
|
||||
let good = psd(2, 4, (3, 2), 8, 3, &[], &[], &layer_count(-1, true), &raw(&[&[1; 6], &[2; 6], &[3; 6], &[4; 6]]));
|
||||
|
||||
@@ -182,7 +182,8 @@ fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor
|
||||
let item = item_target(s, p, cmd)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active, cmd)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let ids = item.targets(s, p)?;
|
||||
// The art of a graph design in a series keeps the design's gradient.
|
||||
let ids = super::appearance::without_design_art(&s.doc()?.doc, item.targets(s, p)?, cmd)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select an object with a freeform gradient"));
|
||||
}
|
||||
|
||||
@@ -531,6 +531,10 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["angle"], C)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let ids = item.targets(s, p)?;
|
||||
// Objects were given or selected, even when all of them are graph design art left out below: the stroke mode is then
|
||||
// not one for new art.
|
||||
let selected = !ids.is_empty();
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
|
||||
// How the gradient lies on a stroke (the panel's Stroke buttons).
|
||||
let mode = match str_param(p, "strokeMode") {
|
||||
None => None,
|
||||
@@ -605,7 +609,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(m) = mode
|
||||
&& ids.is_empty()
|
||||
&& !selected
|
||||
&& p.get("ids").is_none()
|
||||
&& let Some(st) = s.new_art_stroke_mut()
|
||||
{
|
||||
@@ -760,6 +764,7 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if targets.is_empty() {
|
||||
return Err(EngineError::Other("nothing selected".into()));
|
||||
}
|
||||
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| {
|
||||
if index.is_none()
|
||||
|
||||
@@ -416,9 +416,186 @@ fn refused_on_design_art(s: &mut Session, cmd: &str, p: serde_json::Value) {
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("drawn again from the design"), "{cmd}: {e}");
|
||||
// `appearance.addEffect` is an alias of `effect.apply`, whose errors name `effect.apply`.
|
||||
let named = if cmd == "appearance.addEffect" { "effect.apply" } else { cmd };
|
||||
assert!(e.contains(&format!("`{named}`")), "the error names the command that ran: {e}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}");
|
||||
}
|
||||
|
||||
/// The look of object `id`: its appearance, opacity, blend mode, isolation, knockout and overprint.
|
||||
fn look(s: &Session, id: NodeId) -> String {
|
||||
format!("{:?}", s.doc().unwrap().doc.node(id).unwrap())
|
||||
}
|
||||
|
||||
/// An edit of a graph design's art in a series lasts only until the graph is drawn again: the graph draws the art again
|
||||
/// from the design.
|
||||
#[test]
|
||||
fn an_edit_of_a_graph_designs_art_is_lost_when_the_graph_is_drawn_again() {
|
||||
let (mut s, id, _, art, _) = design_art();
|
||||
let path = |s: &Session| {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let mut out = None;
|
||||
series(&n, 0).children().unwrap()[0].walk(&mut |m| {
|
||||
if out.is_none() && matches!(m.kind, NodeKind::Path { .. }) {
|
||||
out = Some(m.clone());
|
||||
}
|
||||
});
|
||||
out.unwrap()
|
||||
};
|
||||
assert_eq!(path(&s).id, art);
|
||||
s.execute("object.setProps", &json!({"id": art.0, "opacity": 50})).unwrap();
|
||||
assert!((path(&s).opacity - 0.5).abs() < 1e-6);
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"csv": ",2024,2025\nQ1,3,2\nQ2,5,8\nQ3,-1,6"})).unwrap();
|
||||
assert!((path(&s).opacity - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The paint, stroke, gradient and overprint commands leave the art of a graph design in a series as the design draws
|
||||
/// it, and edit the other objects they are given.
|
||||
#[test]
|
||||
fn paint_commands_leave_a_graph_designs_art_out() {
|
||||
let (mut s, _, copy, art, mark) = design_art();
|
||||
let fill = top_row(&s, art, true);
|
||||
let commands = [
|
||||
("stroke.set", json!({"weight": 5})),
|
||||
("stroke.weightUp", json!({})),
|
||||
("stroke.setAdvanced", json!({"arrowScale": [200, 200]})),
|
||||
("paint.editGradient", json!({"kind": "linear"})),
|
||||
("paint.setGradientGeom", json!({"start": [0, 0], "end": [50, 50]})),
|
||||
("object.setOverprint", json!({"fill": true})),
|
||||
("paint.swap", json!({})),
|
||||
("paint.default", json!({})),
|
||||
("paint.invert", json!({"item": fill, "stroke": false})),
|
||||
];
|
||||
let (copy_was, art_was) = (look(&s, copy), look(&s, art));
|
||||
for (cmd, p) in &commands {
|
||||
for ids in [json!([copy.0]), json!([art.0])] {
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let mut q = p.clone();
|
||||
q["ids"] = ids;
|
||||
let _ = s.execute(cmd, &q);
|
||||
assert_eq!((look(&s, copy), look(&s, art)), (copy_was.clone(), art_was.clone()), "{cmd}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}");
|
||||
}
|
||||
let mark_was = look(&s, mark);
|
||||
let mut q = p.clone();
|
||||
q["ids"] = json!([art.0, mark.0]);
|
||||
s.execute(cmd, &q).unwrap();
|
||||
assert_eq!(look(&s, art), art_was, "{cmd}");
|
||||
assert_ne!(look(&s, mark), mark_was, "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
// Selected, without ids.
|
||||
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 5})).unwrap();
|
||||
assert_eq!(look(&s, art), art_was);
|
||||
// A freeform gradient point is refused on the art alone.
|
||||
refused_on_design_art(&mut s, "paint.freeform.addPoint", json!({"ids": [art.0], "at": [0, 0]}));
|
||||
}
|
||||
|
||||
/// `attributes.info` reads the overprint of a graph design's art in a series, which the overprint edits leave out.
|
||||
#[test]
|
||||
fn attributes_info_reads_a_graph_designs_art() {
|
||||
let (mut s, _, _, art, _) = design_art();
|
||||
let info = s.execute("attributes.info", &json!({"ids": [art.0]})).unwrap();
|
||||
assert_eq!(info["ids"], json!([art.0]));
|
||||
assert_eq!(info["overprintFill"], json!(false), "{info}");
|
||||
assert_eq!(info["overprintStroke"], json!(false), "{info}");
|
||||
}
|
||||
|
||||
/// With only the art of a graph design selected, `stroke.setAdvanced` and the stroke mode of `paint.editGradient` act
|
||||
/// as edits of a selection, not of the stroke the next object drawn takes.
|
||||
#[test]
|
||||
fn stroke_edits_of_selected_design_art_leave_the_new_art_stroke() {
|
||||
let (mut s, _, _, art, _) = design_art();
|
||||
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
|
||||
let was = format!("{:?}", s.new_art_stroke());
|
||||
s.execute("stroke.setAdvanced", &json!({"arrowScale": [200, 200], "swapArrows": true})).unwrap();
|
||||
assert_eq!(format!("{:?}", s.new_art_stroke()), was, "stroke.setAdvanced");
|
||||
let mode = s.new_art_stroke().gradient_mode;
|
||||
s.execute("paint.editGradient", &json!({"kind": "linear", "stroke": true, "strokeMode": "along"})).unwrap();
|
||||
assert_eq!(s.new_art_stroke().gradient_mode, mode, "paint.editGradient");
|
||||
}
|
||||
|
||||
/// `transparency.set` without `item` refuses the art of a graph design in a series, and given it with other objects
|
||||
/// sets only theirs.
|
||||
#[test]
|
||||
fn object_transparency_leaves_a_graph_designs_art_out() {
|
||||
let (mut s, _, copy, art, mark) = design_art();
|
||||
refused_on_design_art(&mut s, "transparency.set", json!({"ids": [art.0], "opacity": 40}));
|
||||
refused_on_design_art(&mut s, "transparency.set", json!({"ids": [copy.0], "isolate": true}));
|
||||
s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "opacity": 40, "blend": "multiply"})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!((d.node(art).unwrap().opacity - 1.0).abs() < 1e-6);
|
||||
assert!((d.node(mark).unwrap().opacity - 0.4).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// With the art of a graph design selected first, the Appearance panel's active row (a row of that art) stands for no
|
||||
/// row of the other objects selected: an effect goes on each of them as a whole.
|
||||
#[test]
|
||||
fn an_active_row_of_a_graph_designs_art_is_not_applied_to_other_objects() {
|
||||
let (mut s, _, _, art, mark) = design_art();
|
||||
let stroke = top_row(&s, art, false);
|
||||
s.execute("select.set", &json!({"ids": [art.0, mark.0]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.subjects().first(), Some(&art));
|
||||
s.execute("appearance.setActiveItem", &json!({"index": stroke})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(mark).unwrap().clone();
|
||||
assert_eq!(n.appearance.effects.len(), 1);
|
||||
assert!(n.appearance.items.iter().all(|it| it.effects().is_empty()));
|
||||
assert!(s.doc().unwrap().doc.node(art).unwrap().appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
/// Expand Appearance leaves the art of a graph design in a series as the design draws it.
|
||||
#[test]
|
||||
fn expand_appearance_leaves_a_graph_designs_art() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [r]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Shadow"})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0], "design": "Shadow"})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let mut art = vec![];
|
||||
series(&n, 0).children().unwrap()[0].walk(&mut |m| {
|
||||
if !m.appearance.effects.is_empty() {
|
||||
art.push(m.id);
|
||||
}
|
||||
});
|
||||
let art = art[0];
|
||||
let other = s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [other]})).unwrap();
|
||||
let art_was = look(&s, art);
|
||||
// Given alone, by id or selected, it leaves Expand Appearance disabled.
|
||||
for p in [json!({"ids": [art.0]}), json!({})] {
|
||||
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
|
||||
assert!(s.execute("effect.expandAppearance", &p).unwrap_err().to_string().contains("not available"));
|
||||
assert_eq!(look(&s, art), art_was);
|
||||
}
|
||||
// Inside a selected graph or series group, the art stays as the design draws it: with nothing else to expand there,
|
||||
// Expand Appearance is disabled, and with a mark's effect, the mark expands alone.
|
||||
let series_group = series(&n, 0).id;
|
||||
for root in [id, series_group] {
|
||||
s.execute("select.set", &json!({"ids": [root.0]})).unwrap();
|
||||
assert!(s.execute("effect.expandAppearance", &json!({})).unwrap_err().to_string().contains("not available"));
|
||||
assert_eq!(look(&s, art), art_was);
|
||||
}
|
||||
let mark = series(&n, 1).children().unwrap()[0].id;
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let expanded = s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(expanded["ids"], json!([mark.0]));
|
||||
assert_eq!(look(&s, art), art_was);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Selected with another object, the art is left as it is and the other object expands.
|
||||
s.execute("select.set", &json!({"ids": [art.0, other]})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(look(&s, art), art_was);
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none_or(|m| m.appearance.effects.is_empty()));
|
||||
}
|
||||
|
||||
/// The commands that give rows, effects or graphic styles refuse the art of a graph design inside a series, given by
|
||||
/// id (the design's copy or a path inside it) or selected: the graph draws that art again from the design.
|
||||
#[test]
|
||||
@@ -445,6 +622,7 @@ fn appearance_commands_refuse_a_graph_designs_art_in_a_series() {
|
||||
refused_on_design_art(&mut s, "effect.remove", json!({"ids": ids, "index": 0}));
|
||||
refused_on_design_art(&mut s, "threeD.mapGraphic", json!({"ids": ids, "symbol": "Mark"}));
|
||||
refused_on_design_art(&mut s, "graphicStyle.apply", json!({"name": "Blue", "ids": ids}));
|
||||
refused_on_design_art(&mut s, "graphicStyle.breakLink", json!({"ids": ids}));
|
||||
}
|
||||
for cmd in ["appearance.removeItem", "appearance.duplicateItem"] {
|
||||
refused_on_design_art(&mut s, cmd, json!({"ids": [art.0], "index": fill}));
|
||||
@@ -499,6 +677,269 @@ fn appearance_commands_edit_the_objects_given_with_a_graph_designs_art() {
|
||||
assert_eq!(rows(&s, mark).items.len(), mark_was.items.len() + 1);
|
||||
}
|
||||
|
||||
/// `design_art()` with the design's art painted with a red and a blue fill and series 1's first mark with a blue fill and
|
||||
/// a black stroke, so that row 1 of the art (its blue fill) is row 0 of the mark: the graph, the art and the mark.
|
||||
fn aligned_design_art() -> (Session, NodeId, NodeId, NodeId) {
|
||||
let (mut s, id, _, art, mark) = design_art();
|
||||
let (red, blue) = (Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)), Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)));
|
||||
let mark_look = vectorcraft_doc::Appearance::basic(blue, Paint::solid(vectorcraft_color::Color::BLACK), 1.0);
|
||||
let mut art_look = mark_look.clone();
|
||||
art_look.items = vec![art_look.items[0].clone(), art_look.items[0].clone()];
|
||||
*art_look.items[0].paint_mut() = red;
|
||||
s.edit("Looks", |d, _| {
|
||||
d.node_mut(art).unwrap().appearance = vectorcraft_doc::Shared::new(art_look.clone());
|
||||
d.node_mut(mark).unwrap().appearance = vectorcraft_doc::Shared::new(mark_look.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(s, id, art, mark)
|
||||
}
|
||||
|
||||
/// Aligned rows given with the art of a graph design in a series first are rows of that art's stack, which the commands
|
||||
/// leave as it is: each other object edits the row equal to the given one, never the row at the same index.
|
||||
#[test]
|
||||
fn aligned_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));
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
|
||||
s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!(rows(&s, mark).items, mark_was.items[1..], "the mark loses its blue fill and keeps its stroke");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true, "color": "#00ff00"})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!((rows(&s, mark).items[0].paint(), &rows(&s, mark).items[1]), (&green, &mark_was.items[1]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
let fills: Vec<bool> = rows(&s, mark).items.iter().map(|it| it.is_fill()).collect();
|
||||
assert_eq!(fills, [true, true, false], "the mark's blue fill is copied, not its stroke");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 1, "aligned": true, "opacity": 40})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!((rows(&s, mark).items[0].opacity(), rows(&s, mark).items[1].opacity()), (0.4, 1.0));
|
||||
|
||||
// A row of the art past the end of the mark's stack.
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
s.edit("Rows", |d, _| {
|
||||
let ap = &mut d.node_mut(art).unwrap().appearance;
|
||||
let red = ap.items[0].clone();
|
||||
ap.items.insert(0, red);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 2, "aligned": true, "opacity": 40})).unwrap();
|
||||
assert_eq!((rows(&s, mark).items[0].opacity(), rows(&s, mark).items[1].opacity()), (0.4, 1.0));
|
||||
|
||||
// A row of the art (its red fill) that the mark has no equal of: transparency.set is an error, and appearance.setItem
|
||||
// and appearance.removeItem leave the document as it is.
|
||||
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let e = s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 0, "aligned": true, "opacity": 40})).unwrap_err();
|
||||
assert!(e.to_string().contains("no appearance item 0"), "{e}");
|
||||
s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true, "color": "#00ff00"})).unwrap();
|
||||
s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true})).unwrap();
|
||||
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was, mark_was));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no undo step is recorded");
|
||||
}
|
||||
|
||||
/// Aligned effects given with the art of a graph design in a series first go on, and come off, the rows equal to the art's
|
||||
/// row in the other objects; an object without an equal row is left as it is.
|
||||
#[test]
|
||||
fn aligned_effects_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 effects = |s: &Session, id: NodeId| rows(s, id).items.iter().map(|it| it.effects().len()).collect::<Vec<_>>();
|
||||
let (mut s, id, art, mark) = aligned_design_art();
|
||||
// A second mark of series 1 with a green fill, which no row of the art equals, and the same black stroke.
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let other = series(&n, 1).children().unwrap()[1].id;
|
||||
let mut other_look = rows(&s, mark);
|
||||
*other_look.items[0].paint_mut() = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0));
|
||||
s.edit("Look", |d, _| {
|
||||
d.node_mut(other).unwrap().appearance = vectorcraft_doc::Shared::new(other_look.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (art_was, other_was) = (rows(&s, art), rows(&s, other));
|
||||
let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!(effects(&s, mark), [1, 0], "the shadow goes on the mark's blue fill");
|
||||
assert_eq!(rows(&s, other), other_was, "the other mark has no blue fill");
|
||||
assert_eq!((&r["ids"], &r["item"], &r["index"]), (&json!([mark.0]), &json!(0), &json!(0)), "the reply names the mark's row");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// A 3D effect is checked on the rows it goes on only.
|
||||
s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap();
|
||||
assert_eq!((rows(&s, art), effects(&s, mark), rows(&s, other)), (art_was.clone(), vec![1, 0], other_was.clone()));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("appearance.addEffect", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap();
|
||||
assert_eq!((rows(&s, art), effects(&s, mark), rows(&s, other)), (art_was, vec![1, 0], other_was));
|
||||
|
||||
// The art's blue fill with the same shadow as the mark's: removing it from the art's row takes it off the mark's.
|
||||
s.edit("Shadow", |d, _| {
|
||||
let row = d.node(mark).unwrap().appearance.items[0].clone();
|
||||
d.node_mut(art).unwrap().appearance.items[1] = row;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let art_was = rows(&s, art);
|
||||
// The effect dialog reads the shadow from the art's row and passes the art on first.
|
||||
let (target, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": 1, "aligned": true})).unwrap();
|
||||
assert_eq!((fx.len(), &target["ids"], &target["item"]), (1, &json!([art.0, mark.0]), &json!(1)));
|
||||
// Edits of the art's row effect change the mark's equal row.
|
||||
s.execute("effect.setParams", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "visible": false, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert!(!rows(&s, mark).items[0].effects()[0].visible, "the mark's shadow is hidden");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("effect.duplicate", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap();
|
||||
assert_eq!((rows(&s, art), effects(&s, mark)), (art_was.clone(), vec![2, 0]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!(effects(&s, mark), [0, 0]);
|
||||
|
||||
// An object-level effect of the art that the mark does not have: a new object-level effect still goes on the mark.
|
||||
s.edit("Effect", |d, _| {
|
||||
let shadow = d.node(art).unwrap().appearance.items[1].effects()[0].clone();
|
||||
d.node_mut(art).unwrap().appearance.effects.push(shadow);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let art_was = rows(&s, art);
|
||||
s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [art.0, mark.0], "item": null, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!(rows(&s, mark).effects.len(), 1);
|
||||
// The art's object-level shadow is not the mark's roughen: the effect dialog reads the art's, and removing it leaves
|
||||
// the mark as it is.
|
||||
let (_, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": null, "aligned": true})).unwrap();
|
||||
assert_eq!(fx.iter().map(|e| e.id.to_string()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
||||
let mark_was = rows(&s, mark);
|
||||
assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 0, "aligned": true})).is_err());
|
||||
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was));
|
||||
// An object-level effect index past the end of the art's list names no effect of the mark either.
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap();
|
||||
let mark_was = rows(&s, mark);
|
||||
assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 1, "aligned": true})).is_err());
|
||||
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was));
|
||||
// The mark with the art's object-level shadow after its roughen: removing the art's effect 0 takes off the mark's 1.
|
||||
s.edit("Shadow", |d, _| {
|
||||
let shadow = d.node(art).unwrap().appearance.effects[0].clone();
|
||||
d.node_mut(mark).unwrap().appearance.effects[1] = shadow;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 0, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!(rows(&s, mark).effects.iter().map(|e| e.id.to_string()).collect::<Vec<_>>(), ["distort.roughen"]);
|
||||
}
|
||||
|
||||
/// Aligned rows of a graph design's art and of another object whose stack differs are not one place in both stacks, so
|
||||
/// they are not reordered, and `to` is refused for their copies; with the same stacks, the copies go to `to`.
|
||||
#[test]
|
||||
fn aligned_rows_of_a_graph_designs_art_and_a_different_stack_are_not_moved() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
let mark_was = rows(&s, mark);
|
||||
assert!(s.execute("appearance.moveItem", &json!({"ids": [art.0, mark.0], "from": 1, "to": 0, "aligned": true})).is_err());
|
||||
assert_eq!(rows(&s, mark), mark_was);
|
||||
assert!(s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "to": 0, "aligned": true})).is_err());
|
||||
assert_eq!(rows(&s, mark), mark_was);
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [mark.0], "item": null})).unwrap();
|
||||
let mark_was = rows(&s, mark);
|
||||
assert!(s.execute("effect.move", &json!({"ids": [art.0, mark.0], "from": 1, "to": 0, "aligned": true})).is_err());
|
||||
assert_eq!(rows(&s, mark), mark_was);
|
||||
|
||||
// The mark with the art's stack.
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
let art_was = rows(&s, art);
|
||||
s.edit("Look", |d, _| {
|
||||
d.node_mut(mark).unwrap().appearance = vectorcraft_doc::Shared::new(art_was.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "indices": [1, 9], "to": 0, "aligned": true})).is_err());
|
||||
assert_eq!(rows(&s, mark), art_was, "a row that does not exist is refused");
|
||||
s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "to": 0, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
let paints: Vec<_> = rows(&s, mark).items.iter().map(|it| it.paint().clone()).collect();
|
||||
assert_eq!(paints, [art_was.items[1].paint().clone(), art_was.items[0].paint().clone(), art_was.items[1].paint().clone()]);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn aligned_contents_row_of_a_graph_designs_copy_and_a_different_stack_is_not_moved() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
let (mut s, _, copy, _, _) = design_art();
|
||||
s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("appearance.addFill", &json!({"ids": [g.0]})).unwrap();
|
||||
s.execute("appearance.addFill", &json!({"ids": [g.0]})).unwrap();
|
||||
assert_ne!(rows(&s, copy), rows(&s, g));
|
||||
let (copy_was, g_was) = (rows(&s, copy), rows(&s, g));
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let e = s.execute("appearance.moveItem", &json!({"ids": [copy.0, g.0], "from": "contents", "to": 1, "aligned": true})).unwrap_err();
|
||||
assert!(e.to_string().contains("appearances differ"), "{e}");
|
||||
assert_eq!((rows(&s, copy), rows(&s, g)), (copy_was, g_was));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
}
|
||||
|
||||
/// Invert with an `item` remembers the paint of an object it recolored, not that of a graph design's art given first,
|
||||
/// which it leaves out.
|
||||
#[test]
|
||||
fn invert_of_an_item_remembers_the_recolored_paint_not_the_design_arts() {
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
s.execute("paint.invert", &json!({"ids": [art.0, mark.0], "item": 0, "stroke": false})).unwrap();
|
||||
let Paint::Solid { color, .. } = s.doc().unwrap().doc.node(mark).unwrap().appearance.items[0].paint().clone() else {
|
||||
panic!("the mark's fill stays a solid color");
|
||||
};
|
||||
assert_ne!(color, vectorcraft_color::Color::rgb(0.0, 0.0, 1.0), "the mark's blue fill is inverted");
|
||||
assert_eq!(s.last_solid, color, "the mark's inverted fill is remembered, not the art's red one");
|
||||
}
|
||||
|
||||
/// With a graph design's art given first and `aligned: true`, `effect.remove` and `effect.duplicate` with `indices` are an
|
||||
/// error and change nothing when one of the indices names no effect of the art.
|
||||
#[test]
|
||||
fn aligned_effect_indices_past_the_end_of_a_graph_designs_art_are_refused() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap();
|
||||
s.edit("Shadow", |d, _| {
|
||||
let shadow = d.node(mark).unwrap().appearance.effects[0].clone();
|
||||
d.node_mut(art).unwrap().appearance.effects.push(shadow);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
for cmd in ["effect.remove", "effect.duplicate"] {
|
||||
let e = s.execute(cmd, &json!({"ids": [art.0, mark.0], "item": null, "indices": [0, 9], "aligned": true})).unwrap_err();
|
||||
assert!(e.to_string().contains("no effects at indices 0, 9"), "{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}");
|
||||
}
|
||||
// With index 0 alone, which the art has, the mark's equal effect is removed.
|
||||
s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "indices": [0], "aligned": true})).unwrap();
|
||||
assert!(rows(&s, mark).effects.is_empty());
|
||||
}
|
||||
|
||||
/// A blur on a series group makes the whole group an image, which draws the design's art with the series' other art:
|
||||
/// Expand Appearance does not put the art back beside the image.
|
||||
#[test]
|
||||
fn expanding_a_blurred_series_group_takes_a_graph_designs_art_into_the_image() {
|
||||
let (mut s, id, copy, _, _) = design_art();
|
||||
let series_group = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
|
||||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "ids": [series_group.0]})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [series_group.0]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(matches!(d.node(series_group).unwrap().kind, NodeKind::Image(_)));
|
||||
assert!(d.node(copy).is_none());
|
||||
}
|
||||
|
||||
/// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item
|
||||
/// included.
|
||||
#[test]
|
||||
@@ -7372,8 +7813,8 @@ fn graph_commands_refuse_params_of_the_wrong_type() {
|
||||
}
|
||||
|
||||
/// A `.ai` in PostScript form whose graph's records read whole (version 7 specification, appendix A)
|
||||
/// opens as a graph whose data can be edited: Graph Data redraws it on its plot. Saving it as `.ai`
|
||||
/// says its data and settings aren't written, in either form.
|
||||
/// opens as a graph whose data can be edited: Graph Data redraws it on its plot. Saving it as `.ai`,
|
||||
/// in either form, writes its records, and the file opens again with the graph's new data.
|
||||
#[test]
|
||||
fn a_graph_opened_from_ai_keeps_its_data_for_editing() {
|
||||
let graph = "%_Gs\n%_100 200 300 100 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\
|
||||
@@ -7430,10 +7871,60 @@ fn a_graph_opened_from_ai_keeps_its_data_for_editing() {
|
||||
}
|
||||
let r = s.execute("file.saveAs", &p).unwrap();
|
||||
let notes = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}");
|
||||
assert!(!notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}");
|
||||
let mut again = Session::new();
|
||||
again.execute("document.open", &json!({"path": dir.join(name)})).unwrap();
|
||||
let mut graphs = vec![];
|
||||
again.doc().unwrap().doc.walk(|n| graphs.extend(n.extras().graph.as_deref().cloned()));
|
||||
assert_eq!(graphs.len(), 1, "{name}");
|
||||
let g = &graphs[0];
|
||||
assert_eq!(
|
||||
(g.series.as_slice(), g.categories.as_slice(), g.rows.as_slice()),
|
||||
(&after.series[..], &after.categories[..], &after.rows[..]),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(g.kind, after.kind, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph the graph tools draw, of each type, saved as `.ai` in either form, opens again as the same
|
||||
/// graph exactly when the save gives no warning about graphs; a column graph does.
|
||||
#[test]
|
||||
fn a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns() {
|
||||
let dir = vectorcraft_testkit::temp_dir("graph-ai-save");
|
||||
let mut live = vec![];
|
||||
for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,1,6";
|
||||
let id = s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let written = spec(&s, NodeId(id));
|
||||
for (name, version) in [("v7.ai", Some("7")), ("pdf.ai", None)] {
|
||||
let path = dir.join(format!("{ty}-{name}"));
|
||||
let mut p = json!({"path": path, "format": "ai"});
|
||||
if let Some(v) = version {
|
||||
p["aiVersion"] = json!(v);
|
||||
}
|
||||
let r = s.execute("file.saveAs", &p).unwrap();
|
||||
let notes = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
let warned = notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art")));
|
||||
let mut again = Session::new();
|
||||
again.execute("document.open", &json!({"path": path})).unwrap();
|
||||
let mut graphs = vec![];
|
||||
again.doc().unwrap().doc.walk(|n| graphs.extend(n.extras().graph.as_deref().cloned()));
|
||||
assert_eq!(graphs.len(), usize::from(!warned), "{ty} {name}: {r}");
|
||||
if let Some(g) = graphs.first() {
|
||||
assert_eq!((g.kind, g.cells()), (written.kind, written.cells()), "{ty} {name}");
|
||||
assert_eq!((g.series.as_slice(), g.categories.as_slice()), (&written.series[..], &written.categories[..]), "{ty} {name}");
|
||||
live.push(format!("{ty} {name}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(live.contains(&"column v7.ai".to_string()) && live.contains(&"column pdf.ai".to_string()), "{live:?}");
|
||||
}
|
||||
|
||||
/// A graph opened from a `.ai` keeps the bounds of its art without type, so moving one of its labels on its own
|
||||
/// leaves the plot where it is at the first redraw.
|
||||
#[test]
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Scale…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{ids?: [id…] (selected first; default: the selection), sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
"{ids?: [id…] (selected first; default: the selection), sx: % (along the bounding box's width), sy?: % (along its height), origin?: [x,y] (a page point), basis?: \"box\"|\"page\" (box, the default: sx and sy follow the bounding box, turned with rotated objects until Reset Bounding Box; page: the page's axes; a saved step without basis, recorded before it, plays along the page's axes), copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
has_selection,
|
||||
scale
|
||||
),
|
||||
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reflect…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical), origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
"{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical; vertical and horizontal are the bounding box's own axes, deg a line on the page), origin?, basis?: \"box\"|\"page\" (for a named axis; box, the default: the bounding box's axes, turned with rotated objects until Reset Bounding Box; page: the page's axes; a saved step without basis, recorded before it, plays along the page's axes), copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
has_selection,
|
||||
reflect
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::align::choice;
|
||||
use super::{
|
||||
Affine, Arc, Document, EngineError, LastTransform, Node, NodeId, NodeKind, Point, Result, Scaling, Session, Value, Vec2, bad, bool_or,
|
||||
check_numbers, check_switches, duplicate_in, effects, f64_or, f64_req, ids_param, json, matrix_param, ok, outermost, point_param,
|
||||
Affine, Arc, Document, EngineError, LastTransform, Node, NodeId, NodeKind, OrientedBox, Point, Rect, Result, Scaling, Session, Value, Vec2, bad,
|
||||
bool_or, check_numbers, check_switches, duplicate_in, effects, f64_or, f64_req, ids_param, json, matrix_param, ok, outermost, point_param,
|
||||
selected_roots_to_transform,
|
||||
};
|
||||
|
||||
@@ -358,26 +358,52 @@ pub(super) fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if sx == 0.0 || sy == 0.0 {
|
||||
return Err(bad("object.scale", "scale must be non-zero"));
|
||||
}
|
||||
apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), p, true)
|
||||
let frame = basis_frame(s, p, "object.scale", &ids)?;
|
||||
apply_transform(s, "Scale", ids, about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), p, true)
|
||||
}
|
||||
|
||||
/// The frame that `object.scale` and a named `object.reflect` act in, from the `basis` param:
|
||||
/// `"box"` (the default) is the bounding box of `ids`, turned with rotated objects until Reset
|
||||
/// Bounding Box ([`Document::bbox_angle`]); `"page"` is the page's axes. Anchors selected with
|
||||
/// Direct Selection (with no copy and Transform Objects on) transform along the page's axes, as
|
||||
/// their own box does. The basis joins the journal entry, so a replay acts in the same frame. A
|
||||
/// saved step without `basis` ([`Session::replay`]) was recorded before Scale and Reflect followed
|
||||
/// the box, so it acts along the page's axes, as it did when it was recorded.
|
||||
fn basis_frame(s: &mut Session, p: &Value, cmd: &str, ids: &[NodeId]) -> Result<OrientedBox> {
|
||||
let basis = choice(p, cmd, "basis", &["box", "page"])?;
|
||||
let page = basis == Some("page") || (basis.is_none() && s.replaying());
|
||||
let st = s.doc()?;
|
||||
let anchors = !bool_or(p, "copy", false)
|
||||
&& transforms_objects(p)
|
||||
&& effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()).is_some();
|
||||
let angle = if page || anchors { 0.0 } else { st.doc.bbox_angle(ids) };
|
||||
s.note_journal("basis", json!(if page { "page" } else { "box" }));
|
||||
Ok(OrientedBox { angle, rect: Rect::ZERO })
|
||||
}
|
||||
|
||||
pub(super) fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &TRANSFORM_SWITCHES, "object.reflect")?;
|
||||
let ids = selected_roots_to_transform(s)?;
|
||||
let o = origin_of(s, p, &ids)?;
|
||||
let m = match p.get("axis") {
|
||||
Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0),
|
||||
Some(Value::Number(n)) => {
|
||||
let t = -n.as_f64().unwrap_or(90.0).to_radians();
|
||||
Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t)
|
||||
}
|
||||
None | Some(Value::Null) => Affine::scale_non_uniform(-1.0, 1.0),
|
||||
Some(Value::String(a)) if a == "vertical" => Affine::scale_non_uniform(-1.0, 1.0),
|
||||
// A named axis is the bounding box's own (see [`basis_frame`]); an angle is a line on the page.
|
||||
let named = match p.get("axis") {
|
||||
Some(Value::String(a)) if a == "horizontal" => Some(Affine::scale_non_uniform(1.0, -1.0)),
|
||||
Some(Value::Number(_)) => None,
|
||||
None | Some(Value::Null) => Some(Affine::scale_non_uniform(-1.0, 1.0)),
|
||||
Some(Value::String(a)) if a == "vertical" => Some(Affine::scale_non_uniform(-1.0, 1.0)),
|
||||
Some(v) => {
|
||||
let given = v.as_str().map_or_else(|| v.to_string(), str::to_string);
|
||||
return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`")));
|
||||
}
|
||||
};
|
||||
let m = match named {
|
||||
Some(m) => basis_frame(s, p, "object.reflect", &ids)?.conjugate(m),
|
||||
None => {
|
||||
choice(p, "object.reflect", "basis", &["box", "page"])?;
|
||||
let t = -p.get("axis").and_then(Value::as_f64).unwrap_or(90.0).to_radians();
|
||||
Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t)
|
||||
}
|
||||
};
|
||||
apply_transform(s, "Reflect", ids, about(o, m), p, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -156,10 +156,15 @@ pub(super) fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Doc
|
||||
pub(super) type OverprintJob = ((bool, ItemTarget, Vec<NodeId>), bool);
|
||||
|
||||
/// The overprint edits `p` asks for under `keys` (the fill's and the stroke's), aimed as
|
||||
/// `object.setOverprint` aims.
|
||||
/// `object.setOverprint` aims, leaving out the art of graph designs drawn in graph series.
|
||||
pub(super) fn overprint_jobs(s: &Session, p: &Value, cmd: &str, keys: [&str; 2]) -> Result<Vec<OverprintJob>> {
|
||||
let kinds = kinds(p, cmd, keys)?;
|
||||
Ok(aims(s, p, cmd, kinds.iter().map(|k| k.0))?.into_iter().zip(kinds.iter().map(|k| k.1)).collect())
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(aims(s, p, cmd, kinds.iter().map(|k| k.0))?
|
||||
.into_iter()
|
||||
.map(|(fill, it, ids)| (fill, it, super::appearance::drop_design_art(d, ids)))
|
||||
.zip(kinds.iter().map(|k| k.1))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Apply `jobs` to `d`, adding the objects changed to `changed`.
|
||||
|
||||
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transparency",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, 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)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke, and on a series group without an item, the group's own opacity, blend mode, isolate, knockout and knockoutShape (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too",
|
||||
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, 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)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke, and on a series group without an item, the group's own opacity, blend mode, isolate, knockout and knockoutShape (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too",
|
||||
has_doc,
|
||||
transparency
|
||||
),
|
||||
@@ -966,7 +966,7 @@ pub(super) fn restore_unpainted_stroke(ap: &mut Appearance, index: Option<usize>
|
||||
}
|
||||
|
||||
fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = paint_targets(s, p)?;
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, paint_targets(s, p)?);
|
||||
if !ids.is_empty() {
|
||||
// When every object comes out as it was, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
@@ -1001,7 +1001,7 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = paint_targets(s, p)?;
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, paint_targets(s, p)?);
|
||||
if !ids.is_empty() {
|
||||
// When every object comes out as it was, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
@@ -1070,9 +1070,9 @@ 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) = match item {
|
||||
let (r, shown_item, edited) = match item {
|
||||
ItemTarget::Item { index, .. } => {
|
||||
let ids = item.targets(s, p)?;
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?);
|
||||
let mut changed = 0;
|
||||
edit_items(s, &ids, item, cmd, label, !stroke, |n, i| {
|
||||
let paint =
|
||||
@@ -1080,15 +1080,19 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(
|
||||
changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize;
|
||||
Ok(())
|
||||
})?;
|
||||
(json!({ "changed": changed }), Some(index))
|
||||
(json!({ "changed": changed }), Some(index), Some(ids))
|
||||
}
|
||||
// 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)
|
||||
(super::colorcmds::recolor(s, &q, cmd, label, f)?, None, None)
|
||||
}
|
||||
};
|
||||
let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? };
|
||||
// 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))) {
|
||||
s.remember_paint(&shown);
|
||||
}
|
||||
@@ -1142,6 +1146,9 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select objects or give ids"));
|
||||
}
|
||||
// The object-level opacity, blend mode, isolation and knockout of a graph design's art in a series are drawn again
|
||||
// from the design too.
|
||||
let ids = super::appearance::without_design_art(&s.doc()?.doc, ids, C)?;
|
||||
// While an opacity mask is edited the selection is its art, so the Appearance panel's active
|
||||
// item (a row of that art) doesn't stand for the masked object.
|
||||
let editing_mask = p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_some();
|
||||
@@ -1168,13 +1175,22 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return s.execute("object.setProps", &Value::Object(q));
|
||||
};
|
||||
// Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level.
|
||||
let item_ids = if editing_mask { ids } else { row_targets(s, p)? };
|
||||
let item_ids = if editing_mask { ids } else { row_targets(s, p, C)? };
|
||||
// The art of a graph design whose stack an explicit aligned item is a row of goes first: appearance.setItem leaves
|
||||
// it as it is and matches the other objects' rows against it.
|
||||
let of = if editing_mask || !explicit { None } else { super::appearance::design_reference(s, p, C)? };
|
||||
if explicit {
|
||||
let d = &s.doc()?.doc;
|
||||
if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) {
|
||||
// With such art, an object has the item when it has a row equal to the art's.
|
||||
let found = match of {
|
||||
Some(_) => super::appearance::rows_per_target(d, of, &item_ids, &[index], true).iter().any(|r| !r.is_empty()),
|
||||
None => item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())),
|
||||
};
|
||||
if !found {
|
||||
return Err(bad(C, format!("no appearance item {index}")));
|
||||
}
|
||||
}
|
||||
let item_ids = super::appearance::with_reference(of, &item_ids);
|
||||
let (opacity, blend) = (q.remove("opacity"), q.remove("blend"));
|
||||
if let Some(b) = &blend
|
||||
&& b.as_str().and_then(BlendMode::parse).is_none()
|
||||
|
||||
@@ -424,9 +424,9 @@ fn check_effect_types(id: &str, effect: &str, params: &Value) -> Result<()> {
|
||||
/// A value of the type effect `effect` reads for `key`, a parameter its params description names
|
||||
/// without a dialog default: the earlier 3D Perspective and light (`perspective`, `lightAzimuth`,
|
||||
/// `lightElevation`) and Scribble `variation` are numbers, an Outline Stroke's `width` is a number
|
||||
/// and a Gaussian Blur's `linear` is true or false. A Glass or Texturizer `textureImage`, an SVG
|
||||
/// filter's `name` and `xml`, a Warp's `version` and the `style` of Crop Marks have checks of their
|
||||
/// own; they aren't listed.
|
||||
/// and a Gaussian Blur's `linear` is true or false. A Glass, Texturizer or Rough Pastels
|
||||
/// `textureImage`, an SVG filter's `name` and `xml`, a Warp's `version` and the `style` of Crop
|
||||
/// Marks have checks of their own; they aren't listed.
|
||||
pub(crate) fn undefaulted_type(effect: &str, key: &str) -> Option<Value> {
|
||||
use vectorcraft_render::effects;
|
||||
let number = || Some(Value::from(0.0));
|
||||
|
||||
@@ -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 placed in a clip group, which the Links commands take}. 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)} → {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
|
||||
),
|
||||
@@ -113,7 +113,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
/// A file's art at its natural size, before it joins a document.
|
||||
enum Art {
|
||||
Image(RasterImage),
|
||||
/// An image, with the clipping path its file names, in its pixel space (a PSD or PSB file's,
|
||||
/// [`fileio::psdread::clipping_path`]).
|
||||
Image(RasterImage, Option<vectorcraft_geom::PathData>),
|
||||
/// A text file's, an RTF file's or a Word document's text, placed as area type filling the
|
||||
/// natural box.
|
||||
Text(text::Story),
|
||||
@@ -241,7 +243,8 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
|
||||
let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy);
|
||||
let link = src.path.map(|path| super::links::link_info(path, &src.bytes));
|
||||
let warnings = fileio::size_note(&img.blob.bytes).into_iter().collect();
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None });
|
||||
let clip = fileio::psdread::clipping_path(&src.bytes);
|
||||
return Ok(Loaded { name, format, art: Art::Image(img, clip), natural, warnings, link, board: None });
|
||||
}
|
||||
if pdf {
|
||||
// Linked: placed like a VectorCraft document, read again with the page's box only. A page
|
||||
@@ -411,7 +414,7 @@ fn art_of<'a>(nodes: impl Iterator<Item = &'a Arc<Node>>) -> Vec<Node> {
|
||||
/// The art as one object of `d` (its resources added), at its natural size and position.
|
||||
fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
|
||||
let mut node = match l.art {
|
||||
Art::Image(img) => {
|
||||
Art::Image(img, clip) => {
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
super::links::store_image(d, &img.key, img.blob, link.is_some());
|
||||
let im = ImageObject {
|
||||
@@ -423,7 +426,8 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
fileio::file_clipped(d, image, clip)
|
||||
}
|
||||
Art::Dcs(a) => {
|
||||
let a = *a;
|
||||
@@ -485,9 +489,23 @@ 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`); other objects keep their centre.
|
||||
fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool) -> Affine {
|
||||
if image_xf && let NodeKind::Image(im) = &old.kind {
|
||||
/// `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 {
|
||||
// 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.
|
||||
let image = match &old.kind {
|
||||
NodeKind::Group { children, clip: true } if clipped => 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,
|
||||
},
|
||||
_ => old,
|
||||
},
|
||||
_ => old,
|
||||
};
|
||||
if image_xf && let NodeKind::Image(im) = &image.kind {
|
||||
let (sx, sy) = pt_per_px(d.images.get(&im.key).and_then(|b| fileio::ppi::resolution(&b.bytes)));
|
||||
let full = Affine::scale_non_uniform(sx, sy);
|
||||
let centre = full * Point::new(im.width as f64 / 2.0, im.height as f64 / 2.0);
|
||||
@@ -541,7 +559,7 @@ impl PlaceJob {
|
||||
let mut loaded = load(&self.params, PLACE, self.target, true)?;
|
||||
let linked = loaded.link.is_some() && bool_or(&self.params, "link", true);
|
||||
match &mut loaded.art {
|
||||
Art::Image(img) => {
|
||||
Art::Image(img, _) => {
|
||||
let unread = super::links::kept_unread(&img.blob.bytes);
|
||||
img.blob = super::links::readable_and_kept(img.blob.clone(), unread, linked)?;
|
||||
}
|
||||
@@ -657,7 +675,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),
|
||||
(Some(o), _) => replace_xf(&st.doc, o, natural, !is_text, matches!(loaded.art, Art::Image(_, Some(_)))),
|
||||
(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)),
|
||||
@@ -671,7 +689,7 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result<Value> {
|
||||
let link = loaded.link.take().filter(|_| bool_or(p, "link", true));
|
||||
let linked = link.is_some();
|
||||
let (name, format, mut warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings));
|
||||
let dcs_clipped = matches!(&loaded.art, Art::Dcs(a) if a.clip.is_some());
|
||||
let file_clipped = matches!(&loaded.art, Art::Dcs(a) if a.clip.is_some()) || matches!(&loaded.art, Art::Image(_, Some(_)));
|
||||
if let Art::Dcs(a) = &loaded.art {
|
||||
warnings.extend(dcs::swatch_notes(&st.doc, &a.spots));
|
||||
}
|
||||
@@ -697,8 +715,9 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result<Value> {
|
||||
Ok((id, size))
|
||||
})?;
|
||||
let mut out = json!({ "ids": [id.0], "name": name, "format": format, "linked": linked, "width": size.width(), "height": size.height(), "warnings": warnings });
|
||||
// A DCS file clipped by its clipping path: its image, which the Links commands take.
|
||||
if dcs_clipped {
|
||||
// A DCS, PSD or PSB file clipped by its clipping path: its image, which the Links commands
|
||||
// take.
|
||||
if file_clipped {
|
||||
let image = s.doc()?.doc.node(id).and_then(|n| match &n.kind {
|
||||
NodeKind::Group { children, clip: true } => children.get(1).filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.id),
|
||||
_ => None,
|
||||
@@ -810,7 +829,7 @@ fn read_queue(doc: u64, p: Value, queued: Vec<Value>) -> Result<QueueRead> {
|
||||
let checked = load(&q, QUEUE, None, false).and_then(|mut l| {
|
||||
let linked = l.link.is_some() && bool_or(&q, "link", true);
|
||||
match &mut l.art {
|
||||
Art::Image(img) => {
|
||||
Art::Image(img, _) => {
|
||||
let unread = super::links::kept_unread(&img.blob.bytes);
|
||||
let check = if thumbnail.is_some() { super::links::readable_and_kept } else { super::links::readable };
|
||||
img.blob = check(img.blob.clone(), unread, linked)?;
|
||||
@@ -932,7 +951,7 @@ pub fn read_info(p: &Value) -> Result<Value> {
|
||||
"height": natural.height(),
|
||||
"warnings": std::mem::take(&mut l.warnings),
|
||||
});
|
||||
if let Art::Image(img) = &l.art {
|
||||
if let Art::Image(img, _) = &l.art {
|
||||
let (x, y) = img.ppi.unwrap_or((72.0, 72.0));
|
||||
out["pixelWidth"] = json!(img.width);
|
||||
out["pixelHeight"] = json!(img.height);
|
||||
|
||||
@@ -538,8 +538,8 @@ pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result<Value>
|
||||
/// live-effect plug-in) with parameters it takes, filled in to their defaults. A record the object
|
||||
/// already had stays as it was. An object takes one 3D effect (its fills' and strokes' own
|
||||
/// counted), which must fit it, and the `textureImage` of an effect with Load Texture (Glass,
|
||||
/// Texturizer) comes as the effect stores it, never as a file to read: a plug-in can't have the
|
||||
/// host read one.
|
||||
/// Texturizer, Rough Pastels) comes as the effect stores it, never as a file to read: a plug-in
|
||||
/// can't have the host read one.
|
||||
fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result<Vec<Effect>, String> {
|
||||
admit_effects_for("plugin.run", fx, n)
|
||||
}
|
||||
|
||||
@@ -234,6 +234,8 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let item = item_target(s, p, C)?.of_kind(s, false);
|
||||
let mut ids = if item == ItemTarget::Top { own_stroke_or_leaves(s, p)? } else { item.targets(s, p)? };
|
||||
let new_art = no_targets(&ids, p);
|
||||
// The art of a graph design in a series keeps the design's stroke.
|
||||
ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
|
||||
if item == ItemTarget::Top {
|
||||
// Images and symbol instances take no stroke from the panel.
|
||||
let d = &s.doc()?.doc;
|
||||
@@ -368,6 +370,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
|
||||
}
|
||||
return Ok(json!({"objects": 0}));
|
||||
}
|
||||
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| {
|
||||
if index.is_none()
|
||||
@@ -510,6 +513,9 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let item = item_target(s, p, C)?.of_kind(s, false);
|
||||
let ids = item.targets(s, p)?;
|
||||
let new_art = no_targets(&ids, p);
|
||||
// The art of a graph design in a series keeps the design's stroke.
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
|
||||
let arrows_and_profile = |st: &mut StrokeLayer| {
|
||||
if let Some(sc) = scale {
|
||||
st.arrow_scale = sc;
|
||||
@@ -523,7 +529,7 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
// As stroke.set: with nothing selected, for the next object drawn.
|
||||
if no_targets(&ids, p)
|
||||
if new_art
|
||||
&& (scale.is_some() || swap || flip.is_some())
|
||||
&& let Some(st) = s.new_art_stroke_mut()
|
||||
{
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Graphic Style",
|
||||
["Window", "Graphic Styles"],
|
||||
None,
|
||||
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
|
||||
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
|
||||
has_doc,
|
||||
style_apply
|
||||
),
|
||||
@@ -332,7 +332,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = style_index(&s.doc()?.doc, name)?;
|
||||
let add = bool_or(p, "add", false);
|
||||
let chars = s.prefs.override_char_color;
|
||||
let own = super::appearance::edit_targets(s, p)?;
|
||||
let own = super::appearance::edit_targets(s, p, "graphicStyle.apply")?;
|
||||
let ids = super::appearance::rowless_series_marks(&s.doc()?.doc, &own);
|
||||
// The series groups their paths stand for: a style that replaces the appearance clears what they hold themselves,
|
||||
// and a style added on top unlinks them.
|
||||
@@ -521,7 +521,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn style_break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = super::appearance::whole_targets(s, p)?;
|
||||
let ids = super::appearance::whole_targets(s, p, "graphicStyle.breakLink")?;
|
||||
s.edit("Break Link to Graphic Style", |d, _| {
|
||||
set_link(d, &ids, None);
|
||||
Ok(())
|
||||
|
||||
@@ -167,7 +167,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if apply {
|
||||
// Objects that can't take a style are refused before any style is added.
|
||||
super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) })?;
|
||||
super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) }, C)?;
|
||||
}
|
||||
let mut names = names_param(p, C)?;
|
||||
names.extend(text_param(p, "name", C)?.map(str::to_string));
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -140,7 +140,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text; no undo step when no character, paragraph or graph label style changes) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; \"hkna\" is H or V Style, kana in the font's forms for the writing direction, shaped as `vkna` in vertical type, and \"ital\" is Roman Italics, half-width alphanumerics in the font's italic forms, both on or off only), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text; no undo step when no character, paragraph or graph label style changes) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
|
||||
@@ -1589,6 +1589,40 @@ fn proportional_metrics_is_a_character_attribute_saved_only_when_on() {
|
||||
assert!(!serde_json::from_value::<vectorcraft_doc::CharStyle>(old).unwrap().proportional_metrics);
|
||||
}
|
||||
|
||||
/// H or V Style (`hkna`) and Roman Italics (`ital`, OpenType panel menu, East Asian options) are
|
||||
/// feature tags of the characters: set on a range and turned off again, kept by a character style
|
||||
/// and in a saved document, on or off only (`hkna=2` is refused and changes nothing).
|
||||
#[test]
|
||||
fn kana_style_and_roman_italics_are_character_features() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "かなAB"})).unwrap()["id"].as_u64().unwrap();
|
||||
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.features.clone())).collect::<Vec<_>>(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
// か and な are 3 bytes each.
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 6, "features": ["hkna"]})).unwrap();
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 6, "end": 8, "featureChanges": ["ital"]})).unwrap();
|
||||
assert_eq!(runs(&s), [("かな".to_string(), vec!["hkna".to_string()]), ("AB".to_string(), vec!["ital".to_string()])]);
|
||||
let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let NodeKind::Text(t) = &saved.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(t.runs.iter().map(|r| r.style.features.clone()).collect::<Vec<_>>(), [vec!["hkna".to_string()], vec!["ital".to_string()]]);
|
||||
let before = runs(&s);
|
||||
for bad in [json!({"id": id, "features": ["hkna=2"]}), json!({"id": id, "featureChanges": ["ital=1"]})] {
|
||||
let e = s.execute("text.setRangeStyle", &bad).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown OpenType feature"), "{e}");
|
||||
}
|
||||
assert_eq!(runs(&s), before);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "featureChanges": ["-hkna", "-ital"]})).unwrap();
|
||||
assert!(runs(&s).iter().all(|(_, f)| f.is_empty()), "{:?}", runs(&s));
|
||||
s.execute("charStyle.new", &json!({"name": "Kana", "attrs": {"features": ["hkna", "ital"]}})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Kana", "id": id})).unwrap();
|
||||
assert!(runs(&s).iter().all(|(_, f)| *f == ["hkna", "ital"]), "{:?}", runs(&s));
|
||||
let e = s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"features": ["ital=2"]}})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown OpenType feature `ital=2`"), "{e}");
|
||||
}
|
||||
|
||||
/// Tate-chu-yoko (Character panel menu, East Asian options): a character attribute, saved only
|
||||
/// when on. Documents written before it read as off.
|
||||
#[test]
|
||||
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Type on a Path Options…",
|
||||
["Type", "Type on a Path"],
|
||||
None,
|
||||
"{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
|
||||
"{ids?|id?, start?: -1000000..1 (below 0 only on an open path), end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path); on an open path a start below 0 starts the text that far before the path's start, and the characters whose middle falls before it are not drawn (on a closed path it is the path's start), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
|
||||
has_selection,
|
||||
path_options
|
||||
),
|
||||
@@ -1018,7 +1018,18 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| Some(x.clamp(0.0, 1.0))).ok_or_else(|| bad(C, format!("`{k}` is a number 0..1"))),
|
||||
}
|
||||
};
|
||||
let start = fraction("start")?;
|
||||
// On an open path the text may start before the path's start (below 0, see
|
||||
// `TextKind::path_text_start`); on a closed path a start below 0 is the path's start.
|
||||
let start = match p.get("start") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_f64()
|
||||
.filter(|x| x.is_finite())
|
||||
.map(|x| x.clamp(-vectorcraft_doc::PATH_LEAD_MAX, 1.0))
|
||||
.ok_or_else(|| bad(C, "`start` is a number up to 1 (below 0: before an open path's start)"))?,
|
||||
),
|
||||
};
|
||||
let start_on = |path: &vectorcraft_geom::PathData, v: f64| if path.is_closed() { v.max(0.0) } else { v };
|
||||
// `end: null` puts the end bracket back at the end of the path.
|
||||
let end = p.get("end").map(|_| fraction("end")).transpose()?;
|
||||
let flip = switch_or(p, "flip", false, C)?;
|
||||
@@ -1037,8 +1048,8 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Without a flip, when every object already has every value given, the document and its undo
|
||||
// history stay as they are.
|
||||
let has_values = |t: &TextObject| match &t.kind {
|
||||
TextKind::OnPath { start: st, end: en, .. } => {
|
||||
start.is_none_or(|v| v == *st)
|
||||
TextKind::OnPath { path, start: st, end: en } => {
|
||||
start.is_none_or(|v| start_on(path, v) == *st)
|
||||
&& end.is_none_or(|v| v == *en)
|
||||
&& effect.is_none_or(|e| t.path_effect == e)
|
||||
&& align.is_none_or(|a| t.path_align == a)
|
||||
@@ -1053,9 +1064,9 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Type on a Path Options", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let TextKind::OnPath { start: st, end: en, .. } = &mut t.kind else { continue };
|
||||
let TextKind::OnPath { path, start: st, end: en } = &mut t.kind else { continue };
|
||||
if let Some(v) = start {
|
||||
*st = v;
|
||||
*st = start_on(path, v);
|
||||
}
|
||||
if let Some(v) = end {
|
||||
*en = v;
|
||||
|
||||
@@ -1293,7 +1293,12 @@ fn a_transform_that_moves_nothing_records_no_undo_step() {
|
||||
for (id, p) in calls {
|
||||
assert_eq!(s.execute(id, &p).unwrap(), json!({"ids": [a.0]}), "{id} {p}");
|
||||
assert_eq!(history(&s), before, "{id} {p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
|
||||
// The journal adds the frame a scale acted in, so a replay acts in the same one.
|
||||
let mut recorded = p;
|
||||
if id == "object.scale" {
|
||||
recorded["basis"] = json!("box");
|
||||
}
|
||||
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), recorded));
|
||||
}
|
||||
assert_eq!(
|
||||
s.doc().unwrap().last_transform,
|
||||
|
||||
@@ -666,7 +666,7 @@ fn expanding_a_reflected_symbol_instance_keeps_its_box() {
|
||||
for reset in [false, true] {
|
||||
let (mut s, inst) = turned_instance(0.0);
|
||||
s.execute("object.rotate", &json!({"angle": 150})).unwrap();
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal", "basis": "page"})).unwrap();
|
||||
assert!(near(angle(&s, inst), -170.0), "{}", angle(&s, inst));
|
||||
if reset {
|
||||
s.execute("object.resetBoundingBox", &json!({})).unwrap();
|
||||
@@ -719,10 +719,164 @@ fn page_flips_keep_an_upright_box_upright() {
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert_eq!(angle(&s, a), 0.0);
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
// A named axis is the turned box's own: the box keeps its angle.
|
||||
s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap();
|
||||
assert!(near(angle(&s, a), 30.0));
|
||||
s.execute("object.reflect", &json!({"axis": "vertical", "basis": "page"})).unwrap();
|
||||
assert!(near(angle(&s, a), -30.0));
|
||||
}
|
||||
|
||||
fn same_matrix(a: Affine, b: Affine) -> bool {
|
||||
a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y))
|
||||
}
|
||||
|
||||
/// After a rotation, Scale and a named Reflect act along the turned bounding box until Reset
|
||||
/// Bounding Box; `basis: "page"` keeps the page's axes, and Transform Again repeats the matrix the
|
||||
/// transform applied on the page.
|
||||
#[test]
|
||||
fn scale_and_reflect_follow_the_turned_box() {
|
||||
let mut s = session();
|
||||
let a = turned_rect(&mut s);
|
||||
let page = |s: &Session, ids: &[NodeId]| s.doc().unwrap().doc.bounds_of(ids, false).unwrap();
|
||||
let c = Point::new(200.0, 200.0);
|
||||
let frame = OrientedBox { angle: 45.0, rect: Rect::ZERO };
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
|
||||
let b = selection_box(&s);
|
||||
assert!(near(b.angle, 45.0) && near(angle(&s, a), 45.0), "{b:?}");
|
||||
assert!(near(b.rect.width(), 200.0) && near(b.rect.height(), 50.0), "{b:?}");
|
||||
assert!(b.center().distance(c) < 1e-6, "{b:?}");
|
||||
let want = Affine::translate(c.to_vec2()) * frame.conjugate(Affine::scale_non_uniform(2.0, 1.0)) * Affine::translate(-c.to_vec2());
|
||||
let Some(LastTransform::Matrix(m, _)) = s.doc().unwrap().last_transform.clone() else { panic!("no transform to repeat") };
|
||||
assert!(same_matrix(m, want), "{m:?}");
|
||||
// The recorded step names its basis, so a replay acts in the same frame.
|
||||
let steps = s.journal_for_action(0);
|
||||
assert!(steps.iter().any(|(id, p)| id == "object.scale" && p["basis"] == "box"), "{steps:?}");
|
||||
|
||||
// Reflected across the box's vertical axis through its left side: the box keeps its angle and
|
||||
// size, and its centre moves a width to the left along the box.
|
||||
let left = b.reference_point(3);
|
||||
s.execute("object.reflect", &json!({"axis": "vertical", "origin": [left.x, left.y]})).unwrap();
|
||||
let r = selection_box(&s);
|
||||
let moved = c + (frame.to_doc() * Point::new(-200.0, 0.0)).to_vec2();
|
||||
assert!(near(r.angle, 45.0) && near(r.rect.width(), 200.0) && near(r.rect.height(), 50.0), "{r:?}");
|
||||
assert!(r.center().distance(moved) < 1e-6, "{r:?}");
|
||||
// Across its horizontal axis through its centre: the box stays where it is.
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert!(selection_box(&s).center().distance(moved) < 1e-6 && near(angle(&s, a), 45.0));
|
||||
|
||||
// Unknown bases are errors and change nothing.
|
||||
let n = undo_len(&s);
|
||||
assert!(s.execute("object.scale", &json!({"sx": 50, "basis": "turned"})).is_err());
|
||||
assert!(s.execute("object.reflect", &json!({"axis": "vertical", "basis": 1})).is_err());
|
||||
assert!(s.execute("object.reflect", &json!({"axis": 30, "basis": "turned"})).is_err());
|
||||
assert_eq!(undo_len(&s), n);
|
||||
|
||||
// On the page's axes, the box no longer stands at 45°.
|
||||
s.execute("object.scale", &json!({"sx": 50, "sy": 100, "basis": "page"})).unwrap();
|
||||
assert!(!near(angle(&s, a), 45.0));
|
||||
// Transform Again repeats the page matrix after Reset Bounding Box too.
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
|
||||
let Some(LastTransform::Matrix(m, _)) = s.doc().unwrap().last_transform.clone() else { panic!("no transform to repeat") };
|
||||
s.execute("object.resetBoundingBox", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().doc.node(a).unwrap().clone();
|
||||
s.execute("object.transformAgain", &json!({})).unwrap();
|
||||
let mut want = before;
|
||||
want.transform(m, false);
|
||||
let (got, want) = (page(&s, &[a]), want.geometric_bounds_with(&s.doc().unwrap().doc.symbol_boxes()).unwrap());
|
||||
assert!([got.x0 - want.x0, got.y0 - want.y0, got.x1 - want.x1, got.y1 - want.y1].iter().all(|d| d.abs() < 1e-6), "{got:?} {want:?}");
|
||||
}
|
||||
|
||||
/// A saved step without `basis` was recorded before Scale and Reflect followed the turned box, so a
|
||||
/// replay of it acts along the page's axes, as it did then, and its new journal entry says so. A
|
||||
/// step recorded now names its basis and replays along the box. A call without `basis` that is not
|
||||
/// a replay acts along the box.
|
||||
#[test]
|
||||
fn saved_steps_without_a_basis_replay_on_the_page() {
|
||||
// The turned rectangle after `run` runs on it: its page bounds and its box's angle.
|
||||
let after = |run: &dyn Fn(&mut Session)| {
|
||||
let mut s = session();
|
||||
let a = turned_rect(&mut s);
|
||||
run(&mut s);
|
||||
let st = (s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(), angle(&s, a));
|
||||
(st, s.journal.last().unwrap().clone())
|
||||
};
|
||||
let same = |(r, a): (Rect, f64), (q, b): (Rect, f64)| [r.x0 - q.x0, r.y0 - q.y0, r.x1 - q.x1, r.y1 - q.y1, a - b].iter().all(|d| d.abs() < 1e-6);
|
||||
for (id, p) in [("object.scale", json!({"sx": 200, "sy": 100})), ("object.reflect", json!({"axis": "vertical"}))] {
|
||||
let mut on_page = p.clone();
|
||||
on_page["basis"] = json!("page");
|
||||
let (page, _) = after(&|s| {
|
||||
s.execute(id, &on_page).unwrap();
|
||||
});
|
||||
let (old, (_, noted)) = after(&|s| {
|
||||
s.replay(id, &p).unwrap();
|
||||
});
|
||||
assert!(same(old, page), "{id}: {old:?} {page:?}");
|
||||
assert_eq!(noted["basis"], "page", "{id}");
|
||||
|
||||
let (boxed, (recorded_id, recorded)) = after(&|s| {
|
||||
s.execute(id, &p).unwrap();
|
||||
});
|
||||
assert!(!same(boxed, page) && near(boxed.1, 45.0), "{id}: {boxed:?}");
|
||||
assert_eq!(recorded["basis"], "box", "{id}");
|
||||
let (replayed, _) = after(&|s| {
|
||||
s.replay(&recorded_id, &recorded).unwrap();
|
||||
});
|
||||
assert!(same(replayed, boxed), "{id}: {replayed:?} {boxed:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Once Reset Bounding Box squares the box, and for objects standing at different angles, Scale
|
||||
/// acts along the page's axes.
|
||||
#[test]
|
||||
fn scale_follows_the_page_after_a_reset_and_for_mixed_angles() {
|
||||
let mut s = session();
|
||||
let a = turned_rect(&mut s);
|
||||
s.execute("object.resetBoundingBox", &json!({})).unwrap();
|
||||
let page = |s: &Session, ids: &[NodeId]| s.doc().unwrap().doc.bounds_of(ids, false).unwrap();
|
||||
let w = page(&s, &[a]);
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
|
||||
let r = page(&s, &[a]);
|
||||
assert!(near(r.width(), 2.0 * w.width()) && near(r.height(), w.height()), "{w:?} {r:?}");
|
||||
assert_eq!(angle(&s, a), 0.0);
|
||||
// A turned rectangle with an upright one: the box is square to the page.
|
||||
let b = turned_rect(&mut s);
|
||||
let u = rect(&mut s, 400.0, 100.0, 50.0, 50.0);
|
||||
sel(&mut s, &[b, u]);
|
||||
let w = page(&s, &[b, u]);
|
||||
s.execute("object.scale", &json!({"sx": 100, "sy": 200})).unwrap();
|
||||
let r = page(&s, &[b, u]);
|
||||
assert!(near(r.width(), w.width()) && near(r.height(), 2.0 * w.height()), "{w:?} {r:?}");
|
||||
}
|
||||
|
||||
/// With anchors selected, Scale and a named Reflect move the anchors along the page's axes, but
|
||||
/// with Transform Objects off they move the whole objects' pattern tiles, along the turned box.
|
||||
#[test]
|
||||
fn pattern_only_transforms_of_selected_anchors_follow_the_turned_box() {
|
||||
let c = Point::new(200.0, 200.0);
|
||||
let frame = OrientedBox { angle: 45.0, rect: Rect::ZERO };
|
||||
let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2());
|
||||
let last = |s: &Session| match s.doc().unwrap().last_transform.clone() {
|
||||
Some(LastTransform::Matrix(m, _)) => m,
|
||||
other => panic!("no matrix to repeat: {other:?}"),
|
||||
};
|
||||
let scale = Affine::scale_non_uniform(2.0, 1.0);
|
||||
let flip = Affine::scale_non_uniform(-1.0, 1.0);
|
||||
for (cmd, p, m) in [("object.scale", json!({"sx": 200, "sy": 100}), scale), ("object.reflect", json!({"axis": "vertical"}), flip)] {
|
||||
for objects in [false, true] {
|
||||
let mut s = session();
|
||||
let a = turned_rect(&mut s);
|
||||
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap();
|
||||
let mut p = p.clone();
|
||||
p["origin"] = json!([c.x, c.y]);
|
||||
p["objects"] = json!(objects);
|
||||
p["patterns"] = json!(true);
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let want = if objects { about(m) } else { about(frame.conjugate(m)) };
|
||||
assert!(same_matrix(last(&s), want), "{cmd} objects {objects}: {:?}", last(&s));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_type_and_multiple_selections_keep_their_angle() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -115,7 +115,8 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
"scatter-shapes",
|
||||
"game-trails",
|
||||
"energy-beams",
|
||||
"dry-media"
|
||||
"dry-media",
|
||||
"tech-circuit"
|
||||
]
|
||||
);
|
||||
assert!(libs.iter().all(|l| l["category"] == "builtIn"));
|
||||
@@ -217,6 +218,87 @@ fn black_stroke() -> vectorcraft_doc::StrokeLayer {
|
||||
vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0)
|
||||
}
|
||||
|
||||
/// Original circuit art and repeat tiles survive adding, applying and saving a library.
|
||||
#[test]
|
||||
fn the_tech_circuit_library_constructs_adds_and_applies_each_brush() {
|
||||
let mut s = session();
|
||||
let listed = run(&mut s, "brush.libraries", json!({}));
|
||||
let entry = listed["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "tech-circuit").unwrap();
|
||||
assert_eq!((entry["name"].as_str(), entry["category"].as_str(), entry["count"].as_u64()), (Some("Tech Circuit"), Some("builtIn"), Some(3)));
|
||||
let (_, lib) = cmd::brushlib::library(&s, "Tech Circuit").unwrap();
|
||||
let expected = [("Circuit Trace", "art"), ("Bus Lines", "pattern"), ("Terminal Nodes", "pattern")];
|
||||
assert_eq!(lib.brushes.iter().map(|b| (b.name.as_str(), b.kind.type_id())).collect::<Vec<_>>(), expected);
|
||||
let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 20, "x2": 210, "y2": 20}))["id"].as_u64().unwrap();
|
||||
let curve = {
|
||||
let mut p = line_to(40.0);
|
||||
p.curve_to((60.0, -40.0), (140.0, 40.0), (200.0, 0.0));
|
||||
p
|
||||
};
|
||||
let corner = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 80.0, 60.0)).to_bezpath();
|
||||
for b in &lib.brushes {
|
||||
let mut sanitized = b.clone();
|
||||
sanitized.sanitize();
|
||||
assert_eq!(&sanitized, b, "{} changes on load", b.name);
|
||||
for tile in b.art() {
|
||||
let pts = anchors(&[tile]);
|
||||
assert!(!pts.is_empty() && pts.len() <= 100, "{}", b.name);
|
||||
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
|
||||
tile.walk(&mut |n| {
|
||||
if let Some(p) = n.path_data() {
|
||||
assert!(p.subpaths.iter().all(|s| s.closed));
|
||||
}
|
||||
});
|
||||
}
|
||||
if let BrushKind::Pattern(p) = &b.kind {
|
||||
assert_eq!(p.colorization, vectorcraft_brush::Colorization::Tints);
|
||||
assert_eq!(p.spacing, 0.0, "conductors join adjacent tiles");
|
||||
if b.name == "Bus Lines" {
|
||||
let lanes = p.side.children().unwrap();
|
||||
assert_eq!(lanes.len(), 3);
|
||||
let boxes: Vec<_> = lanes.iter().map(|n| n.path_data().unwrap().bounds().unwrap()).collect();
|
||||
for r in &boxes {
|
||||
assert_eq!((r.x0, r.x1), (0.0, 24.0));
|
||||
}
|
||||
assert!(boxes.windows(2).all(|r| r[0].y1 < r[1].y0), "lanes stay separate");
|
||||
}
|
||||
if b.name == "Terminal Nodes" {
|
||||
let mut rings = 0;
|
||||
p.side.walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Compound { rule: vectorcraft_geom::FillRule::EvenOdd, .. }) {
|
||||
rings += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(rings, 1, "a hollow circular test point per tile");
|
||||
}
|
||||
}
|
||||
for p in [line_to(200.0), line_to(2.0), curve.clone(), corner.clone()] {
|
||||
let pieces = vectorcraft_brush::stroke_pieces(&lib.doc, b, &p, &black_stroke());
|
||||
let pts = anchors(&pieces.iter().collect::<Vec<_>>());
|
||||
assert!(!pts.is_empty() && pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{}", b.name);
|
||||
}
|
||||
let before = undo_len(&s);
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "tech-circuit", "name": b.name, "apply": true, "ids": [path]}));
|
||||
assert_eq!(strs(&r["added"]), [b.name.as_str()]);
|
||||
assert_eq!(r["applied"], json!([path]));
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
assert_eq!(brush(&s, &b.name), *b);
|
||||
assert_eq!(doc(&s).node(NodeId(path)).unwrap().appearance.stroke().unwrap().brush.as_deref(), Some(b.name.as_str()));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(vectorcraft_brush::find(doc(&s), &b.name).is_none());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let before = undo_len(&s);
|
||||
let again = run(&mut s, "brush.addFromLibrary", json!({"library": "tech-circuit", "name": b.name}));
|
||||
assert!(strs(&again["added"]).is_empty());
|
||||
assert_eq!(strs(&again["existing"]), [b.name.as_str()]);
|
||||
assert_eq!(undo_len(&s), before);
|
||||
}
|
||||
let names: Vec<&str> = expected.iter().map(|(name, _)| *name).collect();
|
||||
let saved = run(&mut s, "brush.saveLibrary", json!({"names": names}));
|
||||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let reopened = vectorcraft_format::load(&bytes).unwrap();
|
||||
assert_eq!(vectorcraft_brush::library(&reopened), lib.brushes);
|
||||
}
|
||||
|
||||
/// Ink and Wash: a tapered ink art brush, a brush pen whose size follows the pen pressure and two
|
||||
/// translucent washes, with names of their own and a few paths of art each.
|
||||
#[test]
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::appearance::{Effect, FillLayer, StrokeLayer};
|
||||
use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
@@ -2684,6 +2685,401 @@ fn live_paint_make_and_expand_keep_abutting_compound_colors() {
|
||||
check_colors(&node(&s, group));
|
||||
}
|
||||
|
||||
/// Gives `id` the appearance rows `items` (paint order) and returns them.
|
||||
fn set_rows(s: &mut Session, id: NodeId, items: Vec<AppearanceItem>) -> Vec<AppearanceItem> {
|
||||
s.edit("t", |d, _| {
|
||||
d.node_mut(id).unwrap().appearance = vectorcraft_doc::Appearance { items: items.clone(), ..Default::default() }.into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
items
|
||||
}
|
||||
|
||||
/// A fill row of `p`, changed by `f`.
|
||||
fn fill_row(p: Paint, f: impl FnOnce(&mut FillLayer)) -> AppearanceItem {
|
||||
let mut l = FillLayer::new(p);
|
||||
f(&mut l);
|
||||
AppearanceItem::Fill(l)
|
||||
}
|
||||
|
||||
/// A stroke row of `p` `width` wide, changed by `f`.
|
||||
fn stroke_row(p: Paint, width: f64, f: impl FnOnce(&mut StrokeLayer)) -> AppearanceItem {
|
||||
let mut l = StrokeLayer::new(p, width);
|
||||
f(&mut l);
|
||||
AppearanceItem::Stroke(l)
|
||||
}
|
||||
|
||||
/// The faces and edges of Live Paint group `g` whose bounds lie within x `x0`..`x1`.
|
||||
fn parts_within(s: &Session, g: NodeId, x0: f64, x1: f64) -> (Vec<Node>, Vec<Node>) {
|
||||
let n = node(s, g);
|
||||
let within = |c: &&Arc<Node>| c.geometric_bounds().is_some_and(|b| b.x0 >= x0 - 1e-6 && b.x1 <= x1 + 1e-6);
|
||||
let faces = vectorcraft_tools::builder::faces(&n).into_iter().filter(within).map(|c| (**c).clone()).collect();
|
||||
let edges = vectorcraft_tools::builder::edges(&n).into_iter().filter(within).map(|c| (**c).clone()).collect();
|
||||
(faces, edges)
|
||||
}
|
||||
|
||||
/// Make gives each edge every stroke row of its source that draws, a lower one under an
|
||||
/// unpainted or hidden top row included, with the rows' settings and the source's opacity and
|
||||
/// blending mode; rows that draw nothing stay only with the stored source, which Release returns.
|
||||
#[test]
|
||||
fn live_paint_make_keeps_lower_drawn_stroke_rows() {
|
||||
let mut s = session();
|
||||
let (a, b) = two_at(&mut s, 200.0);
|
||||
let black = Paint::solid(Color::BLACK);
|
||||
let green = Paint::solid(Color::rgb(0.0, 1.0, 0.0));
|
||||
let thick = stroke_row(black.clone(), 3.0, |l| {
|
||||
l.opacity = 0.5;
|
||||
l.blend = BlendMode::Multiply;
|
||||
l.cap = LineCap::Round;
|
||||
});
|
||||
// A: the top stroke has no paint.
|
||||
let a_rows =
|
||||
set_rows(&mut s, a, vec![fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |_| {}), thick.clone(), stroke_row(Paint::None, 1.0, |_| {})]);
|
||||
// B: the top stroke is hidden; a zero-weight and a transparent stroke draw nothing either.
|
||||
set_rows(
|
||||
&mut s,
|
||||
b,
|
||||
vec![
|
||||
stroke_row(green.clone(), 2.0, |_| {}),
|
||||
stroke_row(black.clone(), 0.0, |_| {}),
|
||||
stroke_row(black, 2.0, |l| l.opacity = 0.0),
|
||||
stroke_row(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 5.0, |l| l.visible = false),
|
||||
],
|
||||
);
|
||||
s.edit("t", |d, _| {
|
||||
let n = d.node_mut(a).unwrap();
|
||||
n.opacity = 0.75;
|
||||
n.blend = BlendMode::Screen;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let (_, a_edges) = parts_within(&s, g, 0.0, 100.0);
|
||||
let (_, b_edges) = parts_within(&s, g, 200.0, 300.0);
|
||||
assert!(!a_edges.is_empty() && !b_edges.is_empty());
|
||||
for e in &a_edges {
|
||||
assert_eq!(e.appearance.items, vec![thick.clone()], "A's edge takes the stroke that draws");
|
||||
assert_eq!((e.opacity, e.blend), (0.75, BlendMode::Screen));
|
||||
}
|
||||
for e in &b_edges {
|
||||
assert_eq!(e.appearance.items, vec![stroke_row(green.clone(), 2.0, |_| {})], "B's edge takes only its green stroke");
|
||||
assert_eq!((e.opacity, e.blend), (1.0, BlendMode::Normal));
|
||||
}
|
||||
// Release returns the sources with every row they had.
|
||||
let released = ids(&s.execute("livePaint.release", &json!({})).unwrap());
|
||||
assert!(released.contains(&a));
|
||||
assert_eq!(node(&s, a).appearance.items, a_rows);
|
||||
}
|
||||
|
||||
/// When Make cuts a dashed stroke with arrowheads, a width profile and a brush into edges, each
|
||||
/// edge's dashes go on from where it starts along the path, the arrowheads stay at the path's own
|
||||
/// ends, and the profile and brush, which spread over the whole path, are left off the pieces.
|
||||
#[test]
|
||||
fn live_paint_make_fits_dashes_and_arrowheads_to_each_edge() {
|
||||
use vectorcraft_doc::{Arrowhead, Dash};
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let l = line(&mut s, (-20.0, 50.0), (120.0, 50.0));
|
||||
let dash = Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: true };
|
||||
let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| {
|
||||
st.dash = Some(dash.clone());
|
||||
st.start_arrow = Some(Arrowhead::Triangle);
|
||||
st.end_arrow = Some(Arrowhead::Circle);
|
||||
st.brush = Some("Charcoal".into());
|
||||
});
|
||||
let rows = set_rows(&mut s, l, vec![row]);
|
||||
s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let gn = node(&s, g);
|
||||
let mut pieces: Vec<(f64, f64, vectorcraft_doc::StrokeLayer)> = vectorcraft_tools::builder::edges(&gn)
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let st = e.appearance.stroke().filter(|st| st.dash.is_some())?.clone();
|
||||
let sp = e.path_data()?.subpaths.first()?;
|
||||
Some((sp.anchors.first()?.p.x, sp.anchors.last()?.p.x, st))
|
||||
})
|
||||
.collect();
|
||||
pieces.sort_by(|p, q| p.0.total_cmp(&q.0));
|
||||
let ends: Vec<(f64, f64)> = pieces.iter().map(|p| ((p.0 * 1e3).round() / 1e3, (p.1 * 1e3).round() / 1e3)).collect();
|
||||
assert_eq!(ends, vec![(-20.0, 0.0), (0.0, 100.0), (100.0, 120.0)], "each piece runs the way the line does");
|
||||
let offsets: Vec<f64> = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect();
|
||||
assert_eq!(offsets, vec![2.0, 22.0, 122.0], "the dashes go on along the line");
|
||||
assert!(pieces.iter().all(|p| !p.2.dash.as_ref().unwrap().align_corners && p.2.brush.is_none()));
|
||||
let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect();
|
||||
assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, None), (None, Some(Arrowhead::Circle))]);
|
||||
// A path that crosses itself: the pieces that start at the crossing take their place on the
|
||||
// path from where they run, not from the first time the path passes that point.
|
||||
let mut s3 = session();
|
||||
let anchors = [(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)].map(|(x, y)| json!({"x": x, "y": y}));
|
||||
let z = NodeId(s3.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()["id"].as_u64().unwrap());
|
||||
set_rows(&mut s3, z, rows.clone());
|
||||
s3.execute("select.set", &json!({"ids": [z.0]})).unwrap();
|
||||
let g3 = NodeId(s3.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let mut cut: Vec<(f64, Option<Arrowhead>, Option<Arrowhead>)> = vectorcraft_tools::builder::edges(&node(&s3, g3))
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let st = e.appearance.stroke()?;
|
||||
Some(((st.dash.as_ref()?.offset * 1e3).round() / 1e3, st.start_arrow.clone(), st.end_arrow.clone()))
|
||||
})
|
||||
.collect();
|
||||
cut.sort_by(|p, q| p.0.total_cmp(&q.0));
|
||||
let d = 2.0 + 50.0 * 2f64.sqrt();
|
||||
let r = |v: f64| (v * 1e3).round() / 1e3;
|
||||
assert_eq!(
|
||||
cut,
|
||||
vec![(2.0, Some(Arrowhead::Triangle), None), (r(d), None, None), (r(d + 100.0 + 100.0 * 2f64.sqrt()), None, Some(Arrowhead::Circle))]
|
||||
);
|
||||
// An uncut path keeps all of it.
|
||||
let mut s2 = session();
|
||||
let l2 = line(&mut s2, (0.0, 0.0), (50.0, 0.0));
|
||||
set_rows(&mut s2, l2, rows.clone());
|
||||
s2.execute("select.set", &json!({"ids": [l2.0]})).unwrap();
|
||||
let g2 = NodeId(s2.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let edges: Vec<_> = vectorcraft_tools::builder::edges(&node(&s2, g2)).iter().map(|e| e.appearance.items.clone()).collect();
|
||||
assert_eq!(edges, vec![rows]);
|
||||
}
|
||||
|
||||
/// When Gap Options or Merge cuts an edge in two, each piece keeps the edge's dashes going on from
|
||||
/// where it starts along the edge, an arrowhead only at the end of the edge it ends at, and no brush.
|
||||
#[test]
|
||||
fn live_paint_gap_options_and_merge_fit_an_edge_look_to_each_piece() {
|
||||
use vectorcraft_doc::{Arrowhead, Dash};
|
||||
let mut s = session();
|
||||
let l = line(&mut s, (0.0, 50.0), (100.0, 50.0));
|
||||
let stub = line(&mut s, (50.0, 0.0), (50.0, 48.0));
|
||||
let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| {
|
||||
st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false });
|
||||
st.start_arrow = Some(Arrowhead::Triangle);
|
||||
st.end_arrow = Some(Arrowhead::Circle);
|
||||
st.brush = Some("Charcoal".into());
|
||||
});
|
||||
let rows = set_rows(&mut s, l, vec![row]);
|
||||
s.execute("select.set", &json!({"ids": [l.0, stub.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let dashed = |s: &Session, g: NodeId| {
|
||||
let mut out: Vec<(f64, f64, vectorcraft_doc::StrokeLayer)> = vectorcraft_tools::builder::edges(&node(s, g))
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let st = e.appearance.stroke().filter(|st| st.dash.is_some())?.clone();
|
||||
let sp = e.path_data()?.subpaths.first()?;
|
||||
Some(((sp.anchors.first()?.p.x * 1e3).round() / 1e3, (sp.anchors.last()?.p.x * 1e3).round() / 1e3, st))
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|p, q| p.0.total_cmp(&q.0));
|
||||
out
|
||||
};
|
||||
let before = dashed(&s, g);
|
||||
assert_eq!(before.len(), 1, "nothing cuts the line yet");
|
||||
assert_eq!(vec![AppearanceItem::Stroke(before[0].2.clone())], rows);
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true})).unwrap();
|
||||
assert_eq!(r["closed"], json!(1));
|
||||
let pieces = dashed(&s, g);
|
||||
let ends: Vec<(f64, f64)> = pieces.iter().map(|p| (p.0, p.1)).collect();
|
||||
assert_eq!(ends, vec![(0.0, 50.0), (50.0, 100.0)], "the line is cut where the gap closes");
|
||||
let offsets: Vec<f64> = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect();
|
||||
assert_eq!(offsets, vec![2.0, 52.0], "the dashes go on along the line");
|
||||
let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect();
|
||||
assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, Some(Arrowhead::Circle))]);
|
||||
assert!(pieces.iter().all(|p| p.2.brush.is_none()), "a brush spreads over the whole line");
|
||||
// Merge: the piece of the edge that an added path cuts off, which takes the edge's look, gets
|
||||
// it fitted to where it lies along the edge in the same way.
|
||||
let mut s = session();
|
||||
let l = line(&mut s, (0.0, 50.0), (100.0, 50.0));
|
||||
set_rows(&mut s, l, rows.clone());
|
||||
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let cut = line(&mut s, (25.0, 0.0), (25.0, 100.0));
|
||||
s.execute("livePaint.merge", &json!({"ids": [g.0, cut.0]})).unwrap();
|
||||
let pieces = dashed(&s, g);
|
||||
let ends: Vec<(f64, f64)> = pieces.iter().map(|p| (p.0, p.1)).collect();
|
||||
assert_eq!(ends, vec![(0.0, 25.0), (25.0, 100.0)], "the added line cuts the edge");
|
||||
let offsets: Vec<f64> = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect();
|
||||
assert_eq!(offsets, vec![2.0, 27.0], "the dashes go on along the line");
|
||||
let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect();
|
||||
assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, Some(Arrowhead::Circle))]);
|
||||
assert!(pieces.iter().all(|p| p.2.brush.is_none()), "a brush spreads over the whole line");
|
||||
// A repainted edge that Merge cuts keeps its paint on every piece.
|
||||
let mut s = session();
|
||||
let l = line(&mut s, (0.0, 50.0), (100.0, 50.0));
|
||||
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 50], "color": "#ff0000", "width": 3})).unwrap();
|
||||
let cut = line(&mut s, (50.0, 0.0), (50.0, 100.0));
|
||||
s.execute("livePaint.merge", &json!({"ids": [g.0, cut.0]})).unwrap();
|
||||
let gn = node(&s, g);
|
||||
let red = Paint::solid(Color::from_hex("#ff0000").unwrap());
|
||||
let mut on_line: Vec<(f64, bool)> = vectorcraft_tools::builder::edges(&gn)
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let sp = e.path_data()?.subpaths.first()?;
|
||||
let (a, z) = (sp.anchors.first()?.p, sp.anchors.last()?.p);
|
||||
((a.y - 50.0).abs() < 1e-6 && (z.y - 50.0).abs() < 1e-6)
|
||||
.then(|| ((a.x.min(z.x) * 1e3).round() / 1e3, e.appearance.stroke().is_some_and(|st| st.paint == red)))
|
||||
})
|
||||
.collect();
|
||||
on_line.sort_by(|p, q| p.0.total_cmp(&q.0));
|
||||
assert_eq!(on_line, vec![(0.0, true), (50.0, true)], "both pieces keep the edge's paint");
|
||||
}
|
||||
|
||||
/// The edge Make builds from a closed path that nothing cuts starts at the anchor the path starts
|
||||
/// at and runs the way the path does, whichever anchor that is, so its dashes and brush lie where
|
||||
/// they do on the path, and it keeps the path's stroke row as it is.
|
||||
#[test]
|
||||
fn live_paint_make_starts_a_closed_edge_where_its_path_starts() {
|
||||
use vectorcraft_doc::Dash;
|
||||
let corners = [(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)];
|
||||
for clockwise in [true, false] {
|
||||
for start in 0..4 {
|
||||
let mut s = session();
|
||||
let mut pts: Vec<(f64, f64)> = (0..4).map(|k| corners[(k + start) % 4]).collect();
|
||||
if !clockwise {
|
||||
pts.reverse();
|
||||
}
|
||||
let anchors: Vec<_> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
|
||||
let z = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap());
|
||||
let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| {
|
||||
st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false });
|
||||
st.brush = Some("Charcoal".into());
|
||||
});
|
||||
let rows = set_rows(&mut s, z, vec![row]);
|
||||
s.execute("select.set", &json!({"ids": [z.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let gn = node(&s, g);
|
||||
let edges = vectorcraft_tools::builder::edges(&gn);
|
||||
let [e] = edges.as_slice() else { panic!("one edge, got {}", edges.len()) };
|
||||
let sp = e.path_data().unwrap().subpaths.first().unwrap().clone();
|
||||
let at: Vec<(f64, f64)> = sp.anchors.iter().map(|a| ((a.p.x * 1e3).round() / 1e3, (a.p.y * 1e3).round() / 1e3)).collect();
|
||||
assert!(sp.closed);
|
||||
assert_eq!(at, pts, "clockwise {clockwise}, start {start}");
|
||||
assert_eq!(e.appearance.items, rows, "clockwise {clockwise}, start {start}");
|
||||
}
|
||||
}
|
||||
// A closed path that crosses itself is cut into loops, and the loop that holds the path's
|
||||
// first anchor is still only part of the path: it keeps no brush.
|
||||
let mut s = session();
|
||||
let anchors = [(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)].map(|(x, y)| json!({"x": x, "y": y}));
|
||||
let z = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap());
|
||||
let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| {
|
||||
st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false });
|
||||
st.brush = Some("Charcoal".into());
|
||||
});
|
||||
set_rows(&mut s, z, vec![row]);
|
||||
s.execute("select.set", &json!({"ids": [z.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let gn = node(&s, g);
|
||||
let edges = vectorcraft_tools::builder::edges(&gn);
|
||||
assert!(edges.len() > 1, "the crossing cuts the path, got {} edge", edges.len());
|
||||
assert!(edges.iter().all(|e| e.appearance.stroke().is_some_and(|st| st.brush.is_none())), "no loop is the whole path");
|
||||
}
|
||||
|
||||
/// When Make cuts a stroke whose only setting that depends on direction is a gradient along or
|
||||
/// across it, each edge runs the way its source path does, so the gradient keeps its direction.
|
||||
#[test]
|
||||
fn live_paint_make_keeps_the_direction_of_gradient_strokes() {
|
||||
use vectorcraft_doc::StrokeGradientMode;
|
||||
for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] {
|
||||
for (x0, x1) in [(-20.0, 120.0), (120.0, -20.0)] {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let l = line(&mut s, (x0, 50.0), (x1, 50.0));
|
||||
let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
|
||||
set_rows(&mut s, l, vec![stroke_row(gradient, 2.0, |st| st.gradient_mode = mode)]);
|
||||
s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let pieces: Vec<(f64, f64)> = vectorcraft_tools::builder::edges(&node(&s, g))
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
e.appearance.stroke().filter(|st| st.gradient_mode == mode)?;
|
||||
let sp = e.path_data()?.subpaths.first()?;
|
||||
Some((sp.anchors.first()?.p.x, sp.anchors.last()?.p.x))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(pieces.len(), 3, "{mode:?}, from {x0}");
|
||||
assert!(
|
||||
pieces.iter().all(|&(p, q)| (q - p).signum() == (x1 - x0).signum()),
|
||||
"{mode:?}, from {x0}: each piece runs the way the line does: {pieces:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make gives each face every fill row of its source that draws, in order and with their
|
||||
/// opacity, blending mode, overprint and effects, and the source's gradients keep their fit on
|
||||
/// the source; Expand keeps the rows, and Undo and Redo bring the group back with them.
|
||||
#[test]
|
||||
fn live_paint_make_keeps_all_drawn_fill_rows_and_attributes() {
|
||||
let mut s = session();
|
||||
let (a, b) = two(&mut s);
|
||||
let lower = fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |l| {
|
||||
l.opacity = 0.4;
|
||||
l.blend = BlendMode::Multiply;
|
||||
l.overprint = true;
|
||||
l.effects.push(Effect { id: "distort.roughen".into(), params: Value::Null, visible: true });
|
||||
});
|
||||
// A: two fills that draw, with a hidden one between them and an unpainted one above them.
|
||||
set_rows(
|
||||
&mut s,
|
||||
a,
|
||||
vec![
|
||||
lower.clone(),
|
||||
fill_row(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), |l| l.visible = false),
|
||||
fill_row(Paint::None, |_| {}),
|
||||
stroke_row(Paint::None, 1.0, |_| {}),
|
||||
],
|
||||
);
|
||||
// The top fill (and the stroke) become a gradient fitted to A's bounds.
|
||||
let (want_fill, _) = paint_with_gradients(&mut s, a);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let want = vec![lower, fill_row(want_fill, |_| {})];
|
||||
let rows_within = |s: &Session, x0: f64, x1: f64| parts_within(s, g, x0, x1).0.iter().map(|f| f.appearance.items.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(rows_within(&s, 0.0, 50.0), vec![want.clone()], "A's face takes both fills that draw, the gradient at its fit on A");
|
||||
let blue = vec![fill_row(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), |_| {})];
|
||||
assert_eq!(rows_within(&s, 50.0, 100.0), vec![blue], "B is in front");
|
||||
s.execute("livePaint.expand", &json!({})).unwrap();
|
||||
let expanded = node(&s, g);
|
||||
assert!(!vectorcraft_tools::builder::is_live_paint(&expanded));
|
||||
let kept =
|
||||
expanded.children().unwrap().iter().filter(|c| c.geometric_bounds().is_some_and(|b| b.x1 <= 50.0 + 1e-6)).any(|c| c.appearance.items == want);
|
||||
assert!(kept, "Expand keeps both fills");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(rows_within(&s, 0.0, 50.0), vec![want.clone()]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(rows_within(&s, 0.0, 50.0), vec![want]);
|
||||
}
|
||||
|
||||
/// A face takes the fill rows of the front-most covering source whose fill draws: a source in
|
||||
/// front whose fill is hidden or transparent, or whose opacity is 0, does not hide the paint of the
|
||||
/// source behind it, and a face that no such source covers has no paint.
|
||||
#[test]
|
||||
fn live_paint_make_uses_frontmost_source_with_drawn_fill() {
|
||||
for front in ["hidden", "clear", "transparent object"] {
|
||||
let mut s = session();
|
||||
let (a, b) = two(&mut s);
|
||||
s.edit("t", |d, _| {
|
||||
let n = d.node_mut(b).unwrap();
|
||||
let mut app = (*n.appearance).clone();
|
||||
match front {
|
||||
"hidden" => app.fill_mut().unwrap().visible = false,
|
||||
"clear" => app.fill_mut().unwrap().opacity = 0.0,
|
||||
_ => n.opacity = 0.0,
|
||||
}
|
||||
n.appearance = app.into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let rows_within = |x0: f64, x1: f64| parts_within(&s, g, x0, x1).0.iter().map(|f| f.appearance.items.clone()).collect::<Vec<_>>();
|
||||
let red = vec![fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |_| {})];
|
||||
assert_eq!(rows_within(50.0, 100.0), vec![red], "{front}: the overlap shows A");
|
||||
assert_eq!(rows_within(100.0, 150.0), vec![vec![fill_row(Paint::None, |_| {})]], "{front}: B alone paints nothing");
|
||||
assert_eq!(parts_within(&s, g, 100.0, 150.0).0[0].opacity, 1.0, "{front}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_fill_assigns_the_face_under_the_point() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -219,9 +219,10 @@ fn rotate_reflect_and_scale_carry_unplaced_gradients() {
|
||||
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
|
||||
let g = fill_geom(&s, id);
|
||||
assert!((g.end.x - g.start.x).abs() < 1e-9 && (g.length() - 100.0).abs() < 1e-9, "vertical after a 90° turn: {g:?}");
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal", "basis": "page"})).unwrap();
|
||||
let r = fill_geom(&s, id);
|
||||
// The vertical vector is centred on the object, so the reflection swaps its ends.
|
||||
// The vertical vector is centred on the object, so the reflection across the page's
|
||||
// horizontal axis swaps its ends.
|
||||
assert!(r.start.distance(g.end) < 1e-9 && r.end.distance(g.start) < 1e-9, "{g:?} → {r:?}");
|
||||
|
||||
// A circle's radial gradient becomes an ellipse under a non-uniform scale.
|
||||
|
||||
@@ -406,7 +406,7 @@ fn a_batch_step_records_what_it_took_from_the_preferences() {
|
||||
]);
|
||||
s.execute("command.batch", &json!({ "commands": steps })).unwrap();
|
||||
let p = &s.journal.last().unwrap().1;
|
||||
assert_eq!(p["commands"][2]["params"], json!({"sx": 300, "strokes": true, "corners": false}));
|
||||
assert_eq!(p["commands"][2]["params"], json!({"sx": 300, "strokes": true, "corners": false, "basis": "box"}));
|
||||
// Replayed with other preferences, the scale still scales the stroke.
|
||||
let r = replayed(&s);
|
||||
assert!(!r.prefs.scale_strokes);
|
||||
|
||||
@@ -127,6 +127,32 @@ fn options_keep_the_brackets() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `start` below 0 starts type on an open path before the path's start, one undo step; on a closed
|
||||
/// path it is the path's start, and a start that is not a number is an error.
|
||||
#[test]
|
||||
fn options_start_type_before_an_open_path() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("type.pathOptions", &json!({"start": -0.25})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (-0.25, None));
|
||||
assert!(near(r["start"].as_f64().unwrap(), -0.25), "{r}");
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// The same start again records nothing.
|
||||
s.execute("type.pathOptions", &json!({"start": -0.25})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// 100 pt before a 400 pt line: the 12 pt word is drawn nowhere.
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
|
||||
assert!(!laid.glyphs.is_empty() && laid.glyphs.iter().all(|g| g.outline.elements().is_empty()));
|
||||
assert!(s.execute("type.pathOptions", &json!({"start": "-0.5"})).is_err());
|
||||
// A flip keeps the stretch from the path's start: the type then starts at the start bracket.
|
||||
s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (0.0, None));
|
||||
let ring = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [200.0, 90.0], "Ring");
|
||||
s.execute("type.pathOptions", &json!({"ids": [ring.0], "start": -0.25})).unwrap();
|
||||
assert_eq!(brackets(&s, ring).0, 0.0);
|
||||
}
|
||||
|
||||
/// Type on a Path Options given the values the type has (OK in the dialog with nothing changed, the
|
||||
/// effect it has from the Type on a Path menu, the end bracket at the end of the path, or the
|
||||
/// query's own reply) records no undo step: the step to redo stays and the document stays
|
||||
|
||||
@@ -1700,6 +1700,7 @@ fn filters_set_effects_and_stroke_styles() {
|
||||
("test.engine.badn", json!([{"id": "plugin.test.engine.fx", "params": {"n": "two"}}]), "`n`"),
|
||||
("test.engine.file", json!([{"id": "distort.glass", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
|
||||
("test.engine.file2", json!([{"id": "texture.texturizer", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
|
||||
("test.engine.file3", json!([{"id": "artistic.roughPastels", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
|
||||
("test.engine.two3d", json!([{"id": "threeD.extrude"}, {"id": "threeD.extrude"}]), "one 3D effect"),
|
||||
] {
|
||||
let e = filter(&mut s, name, fx).unwrap_err().to_string();
|
||||
|
||||
@@ -323,8 +323,7 @@ fn glass_loads_a_texture_saves_it_and_draws_it() {
|
||||
assert_eq!(doc.node(NodeId(g)).unwrap().appearance.effects[0].params, glass_params(&s, g));
|
||||
assert_eq!(Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true).pixels, loaded.pixels);
|
||||
// The same file by path, through Effect Options, stores the same texture.
|
||||
let dir = std::env::temp_dir().join(format!("vc-glass-texture-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dir = vectorcraft_testkit::TempDir::new("glass-texture");
|
||||
let path = dir.join("rings.png");
|
||||
std::fs::write(&path, &png).unwrap();
|
||||
let (mut t, h) = striped();
|
||||
@@ -334,7 +333,7 @@ fn glass_loads_a_texture_saves_it_and_draws_it() {
|
||||
assert_eq!(glass_params(&t, h)["textureImage"], json!(stored));
|
||||
assert_eq!(glass_params(&t, h)["texture"], json!("loaded"));
|
||||
assert_eq!(render(&t).pixels, loaded.pixels);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
drop(dir);
|
||||
// The stored string is taken as it is (as effect.list gives it, and a graphic style copies it).
|
||||
t.execute("effect.apply", &json!({"effect": "distort.glass", "ids": [h], "params": {"textureImage": stored, "texture": "frosted"}})).unwrap();
|
||||
let second = t.doc().unwrap().doc.node(NodeId(h)).unwrap().appearance.effects[1].params.clone();
|
||||
@@ -391,6 +390,53 @@ fn texturizer_loads_a_texture_and_draws_it() {
|
||||
assert_eq!(Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true).pixels, loaded.pixels);
|
||||
}
|
||||
|
||||
/// Effect › Artistic › Rough Pastels › Load Texture: an image file, given as bytes or by path, is
|
||||
/// stored with the effect and picked; it draws as the surface, a save and open keeps it, a file
|
||||
/// that isn't an image is refused without a change, and undo takes it away.
|
||||
#[test]
|
||||
fn rough_pastels_loads_a_texture_saves_it_and_draws_it() {
|
||||
let png = rings_png();
|
||||
let data = vectorcraft_format::base64_encode(&png);
|
||||
let (mut s, g) = striped();
|
||||
let file = json!({"name": "rings.png", "dataBase64": data});
|
||||
s.execute("effect.apply", &json!({"effect": "artistic.roughPastels", "ids": [g], "params": {"relief": 20, "textureImage": file}})).unwrap();
|
||||
let p = glass_params(&s, g);
|
||||
assert_eq!(p["texture"], json!("loaded"), "loading a texture picks it");
|
||||
let stored = p["textureImage"].as_str().unwrap().to_string();
|
||||
assert_eq!(vectorcraft_render::effects::pixel::texture(&stored).unwrap().size(), (12, 10));
|
||||
let loaded = render(&s);
|
||||
for (_, builtin) in vectorcraft_render::effects::pixel::TEXTURES {
|
||||
s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"texture": builtin}})).unwrap();
|
||||
assert!(mean_diff(&loaded, &render(&s)) > 0.001, "{builtin}");
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"texture": "loaded"}})).unwrap();
|
||||
assert_eq!(render(&s).pixels, loaded.pixels, "the texture stayed with the effect");
|
||||
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(doc.node(NodeId(g)).unwrap().appearance.effects[0].params, glass_params(&s, g));
|
||||
assert_eq!(Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true).pixels, loaded.pixels);
|
||||
// A file that isn't an image changes nothing.
|
||||
let junk = vectorcraft_format::base64_encode(b"not an image");
|
||||
let before = glass_params(&s, g);
|
||||
let e = s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"textureImage": {"dataBase64": junk}}})).unwrap_err();
|
||||
assert!(e.to_string().contains("textureImage"), "{e}");
|
||||
let e = s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"textureImage": 5}})).unwrap_err();
|
||||
assert!(e.to_string().contains("textureImage"), "{e}");
|
||||
assert_eq!(glass_params(&s, g), before);
|
||||
// The same file by path, through Effect Options, stores the same texture; undo takes it away.
|
||||
let dir = vectorcraft_testkit::TempDir::new("rough-pastels-texture");
|
||||
let path = dir.join("rings.png");
|
||||
std::fs::write(&path, &png).unwrap();
|
||||
let (mut t, h) = striped();
|
||||
t.execute("effect.apply", &json!({"effect": "artistic.roughPastels", "ids": [h], "params": {"relief": 20}})).unwrap();
|
||||
assert!(glass_params(&t, h).get("textureImage").is_none());
|
||||
t.execute("effect.setParams", &json!({"index": 0, "ids": [h], "params": {"textureImage": {"path": path.to_string_lossy()}}})).unwrap();
|
||||
drop(dir);
|
||||
assert_eq!((&glass_params(&t, h)["textureImage"], &glass_params(&t, h)["texture"]), (&json!(stored), &json!("loaded")));
|
||||
assert_eq!(render(&t).pixels, loaded.pixels);
|
||||
t.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(glass_params(&t, h).get("textureImage").is_none(), "undone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spin_blur_reaches_past_the_corners() {
|
||||
let (mut s, g) = striped();
|
||||
|
||||
@@ -216,7 +216,7 @@ fn the_journal_records_the_scale_options_used() {
|
||||
run(&mut s, "object.scale", json!({"sx": 150}));
|
||||
let (cmd, p) = s.journal.last().unwrap().clone();
|
||||
assert_eq!(cmd, "object.scale");
|
||||
assert_eq!(p, json!({"sx": 150, "strokes": false, "corners": true}));
|
||||
assert_eq!(p, json!({"sx": 150, "strokes": false, "corners": true, "basis": "box"}));
|
||||
// An explicit value stays; moves don't scale, so they record nothing.
|
||||
run(&mut s, "object.scale", json!({"sx": 150, "strokes": true}));
|
||||
assert_eq!(s.journal.last().unwrap().1["strokes"], json!(true));
|
||||
|
||||
@@ -724,6 +724,44 @@ fn touch_type_moves_one_character_and_leaves_the_others_where_they_are() {
|
||||
assert!(s.execute("text.touchType", &json!({"id": id.0, "start": (1u64 << 32) + 1, "dy": 1})).is_err(), "no character there");
|
||||
}
|
||||
|
||||
/// Touch Type does not change a character of type on an open path that is not drawn because it
|
||||
/// falls before the path's start; a drawn character of the same type still changes.
|
||||
#[test]
|
||||
fn touch_type_refuses_a_character_before_an_open_paths_start() {
|
||||
let mut s = session();
|
||||
let pid = s.execute("path.create", &json!({"d": "M100 300 L500 300"})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "Hidden", "size": 12, "at": [100, 300]})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("type.pathOptions", &json!({"start": -0.02})).unwrap();
|
||||
let steps = undo_labels(&s).len();
|
||||
assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": 2})).is_err(), "the H is not drawn");
|
||||
assert_eq!(undo_labels(&s).len(), steps);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 5, "dy": 2})).unwrap();
|
||||
// An All Caps ß is two glyphs: with the first before the path's start and the second drawn, it
|
||||
// still changes.
|
||||
let pid = s.execute("path.create", &json!({"d": "M100 400 L500 400"})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "ße", "size": 40, "at": [100, 400]})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "allCaps": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let adv = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id)).glyphs[0].advance;
|
||||
s.execute("type.pathOptions", &json!({"start": -1.2 * adv / 400.0})).unwrap();
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id));
|
||||
assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path);
|
||||
let drawn = |s: &Session| {
|
||||
let t = obj(s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let g = lay.glyphs.iter().rfind(|g| g.byte == 0 && !g.off_path).unwrap();
|
||||
t.xf * (g.xf * Point::ZERO)
|
||||
};
|
||||
let before = drawn(&s);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": 2})).unwrap();
|
||||
// The drawn glyph rises 2 points.
|
||||
let after = drawn(&s);
|
||||
assert!((after.x - before.x).abs() < 1e-6 && (after.y - (before.y - 2.0)).abs() < 1e-6, "{before:?} -> {after:?}");
|
||||
}
|
||||
|
||||
/// Touch Type on the first character of a list item moves that character and leaves the bullet
|
||||
/// where it is. The bullet stands for no character, so the first character still moves only up
|
||||
/// and down.
|
||||
|
||||
@@ -1854,6 +1854,80 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 4 × 2 pixel RGB PSD file whose clipping path resource (2999) names its saved path "P"
|
||||
/// (resource 2000): a closed subpath of a linked and two unlinked knots, an open subpath, and the
|
||||
/// initial fill record saying the fill starts with all pixels.
|
||||
fn clipped_psd() -> Vec<u8> {
|
||||
let record = |selector: u16, body: &[i32]| {
|
||||
let mut r = selector.to_be_bytes().to_vec();
|
||||
r.extend(body.iter().flat_map(|v| v.to_be_bytes()));
|
||||
r.resize(26, 0);
|
||||
r
|
||||
};
|
||||
let one = 1 << 24;
|
||||
let mut path = record(6, &[]);
|
||||
path.extend(record(8, &[1 << 16]));
|
||||
path.extend(record(0, &[3 << 16]));
|
||||
path.extend(record(1, &[0, 0, 0, one / 2, 0, one]));
|
||||
path.extend(record(2, &[one, one, one, one, one, one]));
|
||||
path.extend(record(2, &[one, 0, one, 0, one, 0]));
|
||||
path.extend(record(3, &[2 << 16]));
|
||||
path.extend(record(5, &[-one, -one, 0, 0, 2 * one, 2 * one]));
|
||||
path.extend(record(4, &[one / 4, one / 4, one / 4, one / 4, one / 4, one / 4]));
|
||||
let mut res = b"8BIM\x07\xd0\x01P".to_vec();
|
||||
res.extend((path.len() as u32).to_be_bytes());
|
||||
res.extend(path);
|
||||
res.extend(b"8BIM\x0b\xb7\0\0");
|
||||
res.extend(8u32.to_be_bytes());
|
||||
res.extend([1, b'P', 0, 0, 0, 0, 0, 0]);
|
||||
let mut f = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x04\0\x08\0\x03".to_vec();
|
||||
f.extend([0; 4]);
|
||||
f.extend((res.len() as u32).to_be_bytes());
|
||||
f.extend(res);
|
||||
f.extend([0; 4]);
|
||||
f.extend([0, 0]);
|
||||
f.extend((0..24u8).map(|v| v * 10));
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clipped_psd_sample_places_in_a_clip_group() {
|
||||
let path = vectorcraft_engine::cmd::fileio::psdread::clipping_path(&clipped_psd()).unwrap();
|
||||
assert_eq!(path.subpaths.iter().map(|s| (s.anchors.len(), s.closed)).collect::<Vec<_>>(), [(4, true), (3, true), (2, false)]);
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "c.psd", "dataBase64": vectorcraft_format::base64_encode(&clipped_psd())})).unwrap();
|
||||
assert!(r["image"].is_u64(), "{r}");
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// Mutated copies of a PSD file with a clipping path (its resources: the path records, their
|
||||
/// selectors, counts and points, and the names) never crash reading the path, placing the file
|
||||
/// or opening its merged image.
|
||||
#[test]
|
||||
fn mutated_psd_clipping_paths_never_panic(cut in 0usize..1000, edits in prop::collection::vec((0usize..300, any::<u8>()), 0..12)) {
|
||||
let mut bytes = clipped_psd();
|
||||
let len = bytes.len();
|
||||
for (at, b) in edits {
|
||||
if let Some(x) = bytes.get_mut(at % len) {
|
||||
*x = b;
|
||||
}
|
||||
}
|
||||
bytes.truncate((cut % (len + 1)).max(8));
|
||||
let r = catch_quiet(|| {
|
||||
let _ = vectorcraft_engine::cmd::fileio::psdread::clipping_path(&bytes);
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let p = json!({"name": "c.psd", "dataBase64": vectorcraft_format::base64_encode(&bytes), "thumbnail": 8});
|
||||
let _ = no_panic(s.execute("file.place", &p));
|
||||
let _ = no_panic(s.execute("document.open", &json!({"name": "c.psd", "dataBase64": vectorcraft_format::base64_encode(&bytes), "layers": false})));
|
||||
});
|
||||
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-bit TIFFs compressed with CCITT Group 3: modified Huffman (Compression 2) in three strips, T.4
|
||||
/// (Compression 3) with two-dimensional rows, fill bits, six EOLs at the end and each byte's lowest
|
||||
/// bit first, and T.4 two-dimensional rows in 16 × 16 tiles.
|
||||
|
||||
@@ -143,6 +143,10 @@
|
||||
//! A path or compound path with a brush the reader reads live is written with an art style whose
|
||||
//! stroke is the brush's filter (`writer::brushes`). A brush is written only when the reader reads
|
||||
//! it back with its settings.
|
||||
//! - Graphs, as the reader reads them (`read/graph.rs`): a graph on the layers whose records the
|
||||
//! reader reads back as the same graph ([`crate::ai_graph`]) is written as its group between
|
||||
//! `%_Gs`, its functional records and `%_GS`, each series' group tagged `Go` right after its end;
|
||||
//! any other graph, and a graph with art left out, as its art, with a warning.
|
||||
//!
|
||||
//! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at
|
||||
//! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the
|
||||
|
||||
@@ -5128,6 +5128,16 @@ fn a_first_baseline_minimum_and_a_path_end_the_reader_cant_read_are_reported() {
|
||||
};
|
||||
assert_eq!(with(on_path(Some(0.999))).0, [writer::TYPE_ATTRIBUTES]);
|
||||
assert!(with(on_path(None)).0.is_empty());
|
||||
// Type that starts before its open path comes back starting at the path's start.
|
||||
let mut before = on_path(None);
|
||||
if let TextKind::OnPath { start, .. } = &mut before.kind {
|
||||
*start = -0.25;
|
||||
}
|
||||
let (warnings, back) = with(before);
|
||||
assert_eq!(warnings, [writer::TYPE_ATTRIBUTES]);
|
||||
let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") };
|
||||
let TextKind::OnPath { start, .. } = t.kind else { panic!("type on a path") };
|
||||
assert!(start.abs() < 1e-3, "{start}");
|
||||
let (warnings, back) = with(on_path(Some(0.5)));
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") };
|
||||
@@ -10907,3 +10917,73 @@ fn a_clipping_path_written_as_its_paint_keeps_one_text_wrap() {
|
||||
same_wraps(&wraps(read_back(&doc).layers[0].children().unwrap()), &[(None, 5.0, true)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph whose records read back whole is written with them, each series' group tagged before
|
||||
/// its name, and opens again as the same live graph, also from a PDF-compatible file's first page.
|
||||
#[test]
|
||||
fn graphs_are_written_with_their_records_and_open_live() {
|
||||
use crate::ai_graph::sample;
|
||||
for (what, doc) in sample::kept() {
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::GRAPHS), "{what}: {:?}", out.warnings);
|
||||
let text = program(&doc);
|
||||
assert_eq!((text.matches("%_Gs\n").count(), text.matches("%_GS\n").count()), (1, 1), "{what}: {text}");
|
||||
assert!(text.contains("U\n%_2 ") && text.contains(" 0 0 Go\n%_/ArtDictionary :\n"), "{what}: the tag comes before the series' name: {text}");
|
||||
let written = sample::graphs(&doc);
|
||||
let graphs = sample::graphs(&read_back(&doc));
|
||||
assert_eq!(graphs.len(), 1, "{what}");
|
||||
assert!(sample::same(&written[0].0, &graphs[0].0), "{what}: {:#?}\n{:#?}", written[0].0, graphs[0].0);
|
||||
assert_eq!(graphs[0].1, written[0].1, "{what}: the members and their series");
|
||||
}
|
||||
let doc = sample::document(sample::spec(vectorcraft_doc::GraphKind::Column));
|
||||
let (p, _) = private_data(&doc, &opts()).unwrap();
|
||||
let pdf =
|
||||
vectorcraft_pdf::export(&doc, &vectorcraft_pdf::PdfOptions { private_data: Some(p), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap();
|
||||
let (read, _) = crate::ai_alone(&vectorcraft_pdf::illustrator_data(&pdf, None).unwrap()).unwrap();
|
||||
let graphs = sample::graphs(&read);
|
||||
assert_eq!(graphs.len(), 1);
|
||||
assert!(sample::same(&sample::graphs(&doc)[0].0, &graphs[0].0));
|
||||
}
|
||||
|
||||
/// A graph whose records wouldn't read back as it is is written as its art, without records, with
|
||||
/// the warning.
|
||||
#[test]
|
||||
fn graphs_the_records_cant_keep_are_written_as_their_art() {
|
||||
use crate::ai_graph::sample;
|
||||
for (what, doc) in sample::unkept() {
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::GRAPHS), "{what}: {:?}", out.warnings);
|
||||
let text = program(&doc);
|
||||
assert!(!text.contains("%_Gs") && !text.contains(" Go\n") && !text.contains("%_GS"), "{what}: {text}");
|
||||
let read = read_back(&doc);
|
||||
assert!(sample::graphs(&read).is_empty(), "{what}");
|
||||
let paths = |d: &Document| {
|
||||
let mut count = 0;
|
||||
d.walk(|n| count += usize::from(matches!(n.kind, NodeKind::Path { .. })));
|
||||
count
|
||||
};
|
||||
assert_eq!(paths(&read), paths(&doc), "{what}: the graph's art is kept");
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph with art left out for the object limit is written as the art there is room for, without
|
||||
/// records or tags, with the warning; with room for all of it, it is written with its records.
|
||||
#[test]
|
||||
fn a_graph_with_art_left_out_is_written_without_records() {
|
||||
use crate::ai_graph::sample;
|
||||
let doc = sample::document(sample::spec(vectorcraft_doc::GraphKind::Column));
|
||||
let all = export_ai_editing(&doc, &opts()).unwrap();
|
||||
let read = crate::import::objects_read(&all.program).unwrap();
|
||||
let room = export_within(&doc, &opts(), read).unwrap();
|
||||
assert_eq!(room.program, all.program);
|
||||
for max in [0, 1, read / 2, read - 1] {
|
||||
let out = export_within(&doc, &opts(), max).unwrap();
|
||||
let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n");
|
||||
assert!(
|
||||
out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT) && out.warnings.iter().any(|w| w == writer::GRAPHS),
|
||||
"{max}: {:?}",
|
||||
out.warnings
|
||||
);
|
||||
assert!(!text.contains("%_Gs") && !text.contains(" Go\n") && !text.contains("%_GS"), "{max}: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,13 @@ pub(super) struct Writer<'a> {
|
||||
shown: Vec<vectorcraft_geom::BezPath>,
|
||||
/// The copy of an image that the last [`Self::image`] wrote as a window, when it did.
|
||||
windowed: Option<images::Windowed>,
|
||||
/// Writing a graph's art with its records ([`Self::graph_records`]): a graph inside it is written
|
||||
/// as its art.
|
||||
in_graph: bool,
|
||||
/// The `Go` line the next object, a graph's series group, is followed by.
|
||||
series_tag: Option<String>,
|
||||
/// Where the `Go` lines of the graph being written start in [`Self::body`].
|
||||
graph_tags: Vec<usize>,
|
||||
}
|
||||
|
||||
impl<'a> Writer<'a> {
|
||||
@@ -367,6 +374,9 @@ impl<'a> Writer<'a> {
|
||||
whole_images: HashMap::new(),
|
||||
shown: vec![],
|
||||
windowed: None,
|
||||
in_graph: false,
|
||||
series_tag: None,
|
||||
graph_tags: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,6 +652,7 @@ impl<'a> Writer<'a> {
|
||||
|
||||
/// One object: its state before it, its transparency, and its name and opacity mask after it.
|
||||
fn node(&mut self, n: &Node) {
|
||||
let tag = self.series_tag.take();
|
||||
if let NodeKind::Layer { .. } = n.kind {
|
||||
return self.write_layer(n, &[]);
|
||||
}
|
||||
@@ -652,7 +663,8 @@ impl<'a> Writer<'a> {
|
||||
if self.in_symbol {
|
||||
self.wrote_fixed |= symbols::redrawn(n);
|
||||
}
|
||||
if n.extras().graph.is_some() && !self.in_look {
|
||||
let graph = if n.extras().graph.is_some() && !self.in_look { self.graph_records(n) } else { None };
|
||||
if n.extras().graph.is_some() && !self.in_look && graph.is_none() {
|
||||
self.warn(GRAPHS);
|
||||
}
|
||||
// On the layers or in an opacity mask's art, outside art cut to a clipping object: with its
|
||||
@@ -680,14 +692,18 @@ impl<'a> Writer<'a> {
|
||||
let start = self.body.len();
|
||||
// An image's copy written as the part that its opacity mask, among other regions, shows.
|
||||
let mut mask_window = None;
|
||||
let charged = self.charges();
|
||||
match &n.kind {
|
||||
NodeKind::Layer { .. } => {}
|
||||
NodeKind::Group { children, clip: true } => self.clip_group(children, xy),
|
||||
NodeKind::Group { children, clip: false } => {
|
||||
if self.open_group("u") {
|
||||
self.in_graph |= graph.is_some();
|
||||
for c in children {
|
||||
self.series_tag = graph.as_ref().and_then(|g| g.tags.get(&c.id).cloned());
|
||||
self.node(c);
|
||||
}
|
||||
self.in_graph &= graph.is_none();
|
||||
self.end_group("U", xy);
|
||||
}
|
||||
}
|
||||
@@ -790,6 +806,11 @@ impl<'a> Writer<'a> {
|
||||
// A group's dictionary goes between its end and its state block.
|
||||
let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start));
|
||||
self.block_end = usize::MAX;
|
||||
// A graph's series group is tagged right after its end, before its dictionary.
|
||||
if let Some(t) = tag {
|
||||
self.graph_tags.push(self.body.len());
|
||||
self.line(&t);
|
||||
}
|
||||
let mask_written = self.dictionary(n);
|
||||
if let Some(b) = block {
|
||||
self.body.push_str(&b);
|
||||
@@ -803,6 +824,41 @@ impl<'a> Writer<'a> {
|
||||
if deform {
|
||||
self.line(&envelopes::plugin_line());
|
||||
}
|
||||
if let Some(g) = graph {
|
||||
self.graph_end(g, start, charged);
|
||||
}
|
||||
}
|
||||
|
||||
/// The records of graph `n` ([`crate::ai_graph::records`]) when it is written as a graph: on the
|
||||
/// layers, outside art cut to a clipping object, symbols' art, live objects' art and another
|
||||
/// graph, and without an art style anywhere in it. `None` when it is written as its art.
|
||||
fn graph_records(&self, n: &Node) -> Option<crate::ai_graph::Records> {
|
||||
fn has_style(n: &Node, styled: &HashMap<NodeId, Arc<Node>>) -> bool {
|
||||
styled.contains_key(&n.id) || n.children().is_some_and(|c| c.iter().any(|c| has_style(c, styled)))
|
||||
}
|
||||
let placed = self.hidden == 0 && self.nest == 0 && self.drawn == 0 && self.clip.is_none() && !self.in_symbol && !self.in_graph;
|
||||
if !placed || has_style(n, &self.styled) {
|
||||
return None;
|
||||
}
|
||||
crate::ai_graph::records(n, self.to_art)
|
||||
}
|
||||
|
||||
/// The graph whose art was written from `start` on, charged from `charged`, ends: its records go
|
||||
/// before the art and `%_GS` after it, or, when art in it was left out ([`Self::take_objects`]),
|
||||
/// its `Go` lines are taken out and it stays its art, with a warning.
|
||||
fn graph_end(&mut self, records: crate::ai_graph::Records, start: usize, charged: Charges) {
|
||||
let tags = std::mem::take(&mut self.graph_tags);
|
||||
if self.body.len() > start && !self.left_out_since(charged) {
|
||||
self.body.insert_str(start, &records.head);
|
||||
self.line(crate::ai_graph::END);
|
||||
return;
|
||||
}
|
||||
for at in tags.into_iter().rev() {
|
||||
if let Some(len) = self.body.get(at..).and_then(|rest| rest.find('\n')) {
|
||||
self.body.replace_range(at..=at + len, "");
|
||||
}
|
||||
}
|
||||
self.warn(GRAPHS);
|
||||
}
|
||||
|
||||
/// Live object `n`, of transparency `xy`, as a group of its art, with `warning` when it is given.
|
||||
|
||||
@@ -440,7 +440,13 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
|
||||
// The text document holds the Ascent first baseline by leaving it out, without a minimum.
|
||||
|| (t.area.first_baseline == FirstBaseline::Ascent && t.area.first_baseline_min != 0.0)))
|
||||
|| (matches!(t.kind, TextKind::OnPath { .. })
|
||||
&& (t.path_effect != PathEffect::default() || t.path_align != PathAlign::default() || t.path_spacing != 0.0 || end_read_as_none(t)))
|
||||
&& (t.path_effect != PathEffect::default()
|
||||
|| t.path_align != PathAlign::default()
|
||||
|| t.path_spacing != 0.0
|
||||
|| end_read_as_none(t)
|
||||
// The writer writes type that starts before its open path starting at the
|
||||
// path's start.
|
||||
|| t.kind.starts_before_path()))
|
||||
}
|
||||
|
||||
/// Key 35 of a run in style `st`, as the reader's `Texts::style` reads it: 3 for Tate-chu-yoko, 1
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
//! A live graph's records, which the legacy `.ai` writer ([`crate::legacy_ai`]) and the editing data
|
||||
//! writer ([`crate::ai_editing`]) both write around a graph's group (Adobe Illustrator File Format
|
||||
//! Specification, version 7.0, §17 and appendix A): `%_Gs`, the functional records on `%_` lines
|
||||
//! (`Gb`, `Gy`, `Gd`, three `Ga` … `GA` axes, `Gz`, `Gc` cells and `GC`), the drawn art in one group
|
||||
//! with each series' group followed by `%_2 column 0 0 Go`, then `%_GS`.
|
||||
//!
|
||||
//! A graph is written so only when the `.ai` reader (`import::ai::read::graph`) reads every record
|
||||
//! back as the same graph: one of the four graph types it reads (grouped and stacked column, line,
|
||||
//! area), the settings it keeps, a table it holds, and drawn art it attaches the graph to (each
|
||||
//! series' group a direct member of the graph's, of paths painted with one solid fill and one plain
|
||||
//! line, and the plot's left and bottom edges drawn as stroked lines). Any other graph is written as
|
||||
//! its art, with a warning.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
|
||||
use kurbo::{PathSeg, Point};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, Node, NodeId, NodeKind, PiePosition,
|
||||
PieSort, TickLength, ValueAxisSide,
|
||||
};
|
||||
use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use crate::ps;
|
||||
|
||||
/// Most cells of a graph's table, its label row and column included.
|
||||
const MAX_CELLS: usize = 2_600_000;
|
||||
|
||||
/// Longest cell string, in bytes (appendix A, `Gc`).
|
||||
const MAX_CELL_TEXT: usize = 255;
|
||||
|
||||
/// Most characters of the text before or after a value axis' numbers (appendix A, `Ga`, `GA`).
|
||||
const MAX_AFFIX: usize = 9;
|
||||
|
||||
/// Most tick marks per division the reader keeps.
|
||||
const MAX_TICK_MARKS: usize = 20;
|
||||
|
||||
/// Most divisions of a value axis with set values the reader keeps.
|
||||
const MAX_DIVISIONS: usize = 100;
|
||||
|
||||
/// How far an axis line may be from the plot's edge, in points.
|
||||
const EDGE: f64 = 0.01;
|
||||
|
||||
/// Most values one `Gc` takes.
|
||||
const CELLS_PER_RECORD: usize = 15;
|
||||
|
||||
/// A graph's records: what goes before its group (`%_Gs` to `%_GC`), and the `Go` line that follows
|
||||
/// each series' group, by the group's id. `%_GS` follows the group.
|
||||
pub(crate) struct Records {
|
||||
pub(crate) head: String,
|
||||
pub(crate) tags: HashMap<NodeId, String>,
|
||||
}
|
||||
|
||||
/// The line that ends a graph's records, after its group.
|
||||
pub(crate) const END: &str = "%_GS";
|
||||
|
||||
/// The records of graph `n` (a group holding [`vectorcraft_doc::NodeExtras::graph`]), its art written
|
||||
/// through `to_art`; `None` when the reader would not read them back as the same graph.
|
||||
pub(crate) fn records(n: &Node, to_art: Affine) -> Option<Records> {
|
||||
let g = n.extras().graph.as_deref()?;
|
||||
let NodeKind::Group { children, clip: false } = &n.kind else { return None };
|
||||
(n.visible && n.extras().mask.is_none() && all_shown(n)).then_some(())?;
|
||||
let ty = kind_code(g.kind)?;
|
||||
settings_kept(g).then_some(())?;
|
||||
let (left_manual, left_min, left_max, left_step) = axis(g.ticks, g.axis_min, g.axis_max)?;
|
||||
let (right_manual, right_min, right_max, right_step) = axis(g.right_ticks, g.right_axis_min, g.right_axis_max)?;
|
||||
// The table as the graph draws it: `cells` leaves out rows and series past the model's limits.
|
||||
let rows = g.rows.len();
|
||||
let width = g.rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
((1..=MAX_GRAPH_CATEGORIES).contains(&rows) && (1..=MAX_GRAPH_SERIES).contains(&width)).then_some(())?;
|
||||
let cells = g.cells();
|
||||
cells.iter().flatten().all(|v| v.is_none_or(f64::is_finite)).then_some(())?;
|
||||
cells.iter().flatten().any(Option::is_some).then_some(())?;
|
||||
let first_row = usize::from(!g.series.is_empty());
|
||||
let first_col = usize::from(!g.categories.is_empty());
|
||||
(g.series.is_empty() || g.series.len() == width).then_some(())?;
|
||||
(g.categories.is_empty() || g.categories.len() == rows).then_some(())?;
|
||||
let (total_rows, total_cols) = (rows + first_row, width + first_col);
|
||||
total_rows.checked_mul(total_cols).filter(|c| *c <= MAX_CELLS)?;
|
||||
g.series.iter().chain(&g.categories).all(|l| l.len() <= MAX_CELL_TEXT && !l.chars().any(char::is_control)).then_some(())?;
|
||||
[&g.prefix, &g.suffix, &g.right_prefix, &g.right_suffix]
|
||||
.iter()
|
||||
.all(|a| a.chars().count() <= MAX_AFFIX && !a.chars().any(char::is_control))
|
||||
.then_some(())?;
|
||||
// The plot, and the art the reader attaches the graph to.
|
||||
let r = g.rect;
|
||||
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(())?;
|
||||
let plot = to_art.transform_rect_bbox(r);
|
||||
(plot.is_finite() && plot.width() > 0.0 && plot.height() > 0.0).then_some(())?;
|
||||
let tags = series_groups(children, width, first_col, g.kind == GraphKind::Line)?;
|
||||
draws_axes(n, r).then_some(())?;
|
||||
let placed = n.geometric_bounds()?;
|
||||
(r.x0 >= placed.x0 - EDGE && r.y0 >= placed.y0 - EDGE && r.x1 <= placed.x1 + EDGE && r.y1 <= placed.y1 + EDGE).then_some(())?;
|
||||
|
||||
let mut head = String::from("%_Gs\n");
|
||||
let _ = writeln!(head, "%_{} {} {} {} Gb", real(plot.x0), real(plot.y1), real(plot.x1), real(plot.y0));
|
||||
let flag = |b: bool| u8::from(b);
|
||||
let _ = writeln!(
|
||||
head,
|
||||
"%_{ty} {} {} 14 {} {} 0 {} 6 44 Gy",
|
||||
flag(g.drop_shadow),
|
||||
flag(g.first_row_in_front),
|
||||
flag(g.mark_points),
|
||||
flag(g.connect_points),
|
||||
flag(g.legend_across_top)
|
||||
);
|
||||
let _ =
|
||||
writeln!(head, "%_10 2 {} 0 {} {} {} Gd", flag(g.first_column_in_front), real(g.column_width), real(g.cluster_width), flag(g.edge_to_edge));
|
||||
// Tick marks per division: 0 and 1 both mean one, at the labels, and the reader reads 0 as 1.
|
||||
let (category_marks, left_marks, right_marks) = (g.category_tick_marks.max(1), g.tick_marks.max(1), g.right_tick_marks.max(1));
|
||||
let _ = writeln!(head, "%_1 () Ga\n%_0 {} 0 0 0 {category_marks} {} () GA", tick(g.category_tick_length), flag(g.ticks_between_labels));
|
||||
let _ = writeln!(head, "%_2 {} Ga", ps::bytes(g.prefix.as_bytes()));
|
||||
let _ = writeln!(
|
||||
head,
|
||||
"%_{} {} {} {} {} {} 0 {} GA",
|
||||
flag(left_manual),
|
||||
tick(g.tick_length),
|
||||
real(left_min),
|
||||
real(left_max),
|
||||
real(left_step),
|
||||
left_marks,
|
||||
ps::bytes(g.suffix.as_bytes())
|
||||
);
|
||||
let _ = writeln!(head, "%_4 {} Ga", ps::bytes(g.right_prefix.as_bytes()));
|
||||
let _ = writeln!(
|
||||
head,
|
||||
"%_{} {} {} {} {} {} 0 {} GA",
|
||||
flag(right_manual),
|
||||
tick(g.right_tick_length),
|
||||
real(right_min),
|
||||
real(right_max),
|
||||
real(right_step),
|
||||
right_marks,
|
||||
ps::bytes(g.right_suffix.as_bytes())
|
||||
);
|
||||
let _ = writeln!(head, "%_{total_rows} {total_cols} {first_row} {first_col} Gz");
|
||||
let mut table = Table::new(&mut head);
|
||||
if first_row == 1 {
|
||||
if first_col == 1 {
|
||||
table.text("");
|
||||
}
|
||||
for s in &g.series {
|
||||
table.text(s);
|
||||
}
|
||||
}
|
||||
for (i, row) in cells.iter().enumerate() {
|
||||
if let Some(c) = g.categories.get(i) {
|
||||
table.text(c);
|
||||
}
|
||||
for v in row {
|
||||
match v {
|
||||
Some(v) => table.num(*v),
|
||||
None => table.text(""),
|
||||
}
|
||||
}
|
||||
}
|
||||
table.finish();
|
||||
head.push_str("%_GC\n");
|
||||
Some(Records { head, tags })
|
||||
}
|
||||
|
||||
/// The `Gc` records of a table, its cells in rows: at most [`CELLS_PER_RECORD`] values each, one
|
||||
/// string at most.
|
||||
struct Table<'a> {
|
||||
out: &'a mut String,
|
||||
values: usize,
|
||||
string: bool,
|
||||
}
|
||||
|
||||
impl<'a> Table<'a> {
|
||||
fn new(out: &'a mut String) -> Self {
|
||||
Self { out, values: 0, string: false }
|
||||
}
|
||||
|
||||
fn start(&mut self, string: bool) {
|
||||
if self.values == CELLS_PER_RECORD || (string && self.string) {
|
||||
self.finish();
|
||||
}
|
||||
self.out.push_str(if self.values == 0 { "%_" } else { " " });
|
||||
self.values += 1;
|
||||
self.string |= string;
|
||||
}
|
||||
|
||||
fn text(&mut self, t: &str) {
|
||||
self.start(true);
|
||||
self.out.push_str(&ps::bytes(t.as_bytes()));
|
||||
}
|
||||
|
||||
fn num(&mut self, v: f64) {
|
||||
self.start(false);
|
||||
self.out.push_str(&real(v));
|
||||
}
|
||||
|
||||
fn finish(&mut self) {
|
||||
if self.values > 0 {
|
||||
self.out.push_str(" Gc\n");
|
||||
}
|
||||
(self.values, self.string) = (0, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` as a PostScript number that reads back as the same value.
|
||||
fn real(v: f64) -> String {
|
||||
if v == 0.0 {
|
||||
"0".into()
|
||||
} else if (1e-4..1e15).contains(&v.abs()) {
|
||||
format!("{v}")
|
||||
} else {
|
||||
format!("{v:e}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `graphType`: 5 grouped column, 6 stacked column, 7 line, 10 area; the reader reads no other type.
|
||||
fn kind_code(k: GraphKind) -> Option<u8> {
|
||||
match k {
|
||||
GraphKind::Column => Some(5),
|
||||
GraphKind::StackedColumn => Some(6),
|
||||
GraphKind::Line => Some(7),
|
||||
GraphKind::Area => Some(10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `tickMarkType`: 13 none, 14 short, 15 the length of the plot.
|
||||
fn tick(t: TickLength) -> u8 {
|
||||
match t {
|
||||
TickLength::None => 13,
|
||||
TickLength::Short => 14,
|
||||
TickLength::Full => 15,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `g`'s settings are ones the records give and the reader keeps: a legend beside or across
|
||||
/// the top, every series of the graph's type, one value axis on the left, lines not filled, no graph
|
||||
/// designs, label formatting or pie settings, column and cluster widths of 1% to 1000%, and at most
|
||||
/// [`MAX_TICK_MARKS`] tick marks a division.
|
||||
fn settings_kept(g: &GraphSpec) -> bool {
|
||||
let percent = |p: f64| (1.0..=1000.0).contains(&p);
|
||||
g.legend
|
||||
&& g.series_kinds.iter().all(|k| k.is_none_or(|k| k == g.kind))
|
||||
&& g.value_axis == ValueAxisSide::Left
|
||||
&& !g.separate_scales
|
||||
&& g.right_series.is_empty()
|
||||
&& !g.filled_lines
|
||||
&& g.series_markers.iter().all(Option::is_none)
|
||||
&& g.series_columns.iter().all(Option::is_none)
|
||||
&& g.label_styles == LabelStyles::default()
|
||||
&& !g.pie_legend_in_wedges
|
||||
&& g.pie_position == PiePosition::Even
|
||||
&& g.pie_sort == PieSort::None
|
||||
&& percent(g.column_width)
|
||||
&& percent(g.cluster_width)
|
||||
&& [g.tick_marks, g.right_tick_marks, g.category_tick_marks].iter().all(|m| *m <= MAX_TICK_MARKS)
|
||||
}
|
||||
|
||||
/// A value axis' `useManualValues`, lowest and highest value and `betweenValue`: automatic (no tick
|
||||
/// count and no values), or a whole number of divisions (1 to [`MAX_DIVISIONS`]) between a lowest
|
||||
/// and a higher highest value, which the reader reads back as the same count.
|
||||
fn axis(ticks: usize, min: Option<f64>, max: Option<f64>) -> Option<(bool, f64, f64, f64)> {
|
||||
match (ticks, min, max) {
|
||||
(0, None, None) => Some((false, 0.0, 0.0, 0.0)),
|
||||
(1..=MAX_DIVISIONS, Some(lo), Some(hi)) if lo.is_finite() && hi.is_finite() && hi > lo => {
|
||||
let step = (hi - lo) / ticks as f64;
|
||||
let n = (hi - lo) / step;
|
||||
let exact = step > 0.0 && step.is_finite() && (n - n.round()).abs() <= 1e-6 * n.round().max(1.0) && n.round() == ticks as f64;
|
||||
exact.then_some((true, lo, hi, step))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `n` and everything in it is shown.
|
||||
fn all_shown(n: &Node) -> bool {
|
||||
n.visible && n.children().is_none_or(|c| c.iter().all(|c| all_shown(c)))
|
||||
}
|
||||
|
||||
/// Whether `n` is shown, fully opaque, in Normal mode, not isolated and without an opacity mask or
|
||||
/// effects: the reader keeps a series' paint only from groups and paths drawn so.
|
||||
fn as_drawn(n: &Node) -> bool {
|
||||
n.visible
|
||||
&& n.opacity == 1.0
|
||||
&& n.blend == BlendMode::Normal
|
||||
&& !n.isolate
|
||||
&& n.extras().mask.is_none()
|
||||
&& !n.appearance.effects.iter().any(|e| e.visible)
|
||||
}
|
||||
|
||||
/// The visible fill and line `p` is written with as one path object: a solid fill, and a solid
|
||||
/// plain line with its weight, each opaque, in Normal mode and without effects of its own. `None`
|
||||
/// when it has other paint, more than one of either, or isn't one path of one subpath.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn plain_paint(p: &Node) -> Option<(Option<Paint>, Option<(Paint, f64)>)> {
|
||||
let NodeKind::Path { path, guide: false, clipping: false, .. } = &p.kind else { return None };
|
||||
(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count() == 1).then_some(())?;
|
||||
let (mut fill, mut line) = (None, None);
|
||||
for item in &p.appearance.items {
|
||||
let drawn = match item {
|
||||
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
|
||||
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && s.width.is_finite(),
|
||||
};
|
||||
if !drawn {
|
||||
// The writers leave out what draws nothing; the reader reads nothing in its place.
|
||||
continue;
|
||||
}
|
||||
let plain =
|
||||
item.opacity() == 1.0 && item.blend() == BlendMode::Normal && item.effects().is_empty() && matches!(item.paint(), Paint::Solid { .. });
|
||||
plain.then_some(())?;
|
||||
match item {
|
||||
AppearanceItem::Fill(f) if fill.is_none() && line.is_none() => fill = Some(f.paint.clone()),
|
||||
AppearanceItem::Stroke(s) if line.is_none() && stroke::is_plain(s) => line = Some((s.paint.clone(), s.width)),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some((fill, line))
|
||||
}
|
||||
|
||||
/// The `Go` line of each series' group among `children`: a group whose series index is one of the
|
||||
/// `series` the table has, each once, of paths [`as_drawn`] with one fill and one line alike
|
||||
/// ([`plain_paint`]), every path filled unless the graph draws lines (`line`). Its column is the
|
||||
/// table's: after the label column when there is one (`first_col`). `None` when a series has no
|
||||
/// such group.
|
||||
fn series_groups(children: &[std::sync::Arc<Node>], series: usize, first_col: usize, line: bool) -> Option<HashMap<NodeId, String>> {
|
||||
let mut tags = HashMap::new();
|
||||
let mut seen = vec![false; series];
|
||||
for c in children {
|
||||
let Some(s) = c.extras().series_index else { continue };
|
||||
let s = usize::try_from(s).ok().filter(|s| *s < series)?;
|
||||
let slot = seen.get_mut(s)?;
|
||||
(!*slot).then_some(())?;
|
||||
*slot = true;
|
||||
let NodeKind::Group { children: paths, clip: false } = &c.kind else { return None };
|
||||
(as_drawn(c) && *c.appearance == Appearance::default() && !paths.is_empty()).then_some(())?;
|
||||
let mut paints = vec![];
|
||||
for p in paths {
|
||||
as_drawn(p).then_some(())?;
|
||||
paints.push(plain_paint(p)?);
|
||||
}
|
||||
let fill = paints.iter().find_map(|(f, _)| f.clone());
|
||||
paints.iter().all(|(f, _)| f.is_none() || *f == fill).then_some(())?;
|
||||
(line || paints.iter().all(|(f, _)| f.is_some())).then_some(())?;
|
||||
let stroke = paints.first().and_then(|(_, s)| s.clone());
|
||||
paints.iter().all(|(_, s)| *s == stroke).then_some(())?;
|
||||
tags.insert(c.id, format!("%_2 {} 0 0 Go", s + first_col));
|
||||
}
|
||||
seen.iter().all(|s| *s).then_some(tags)
|
||||
}
|
||||
|
||||
/// Whether `art` draws plot `plot`'s left edge and its bottom edge (the document's y runs down) as
|
||||
/// straight lines, each a shown path of one subpath with a plain line ([`plain_paint`]) in a shown
|
||||
/// group, as the reader finds the axes.
|
||||
fn draws_axes(art: &Node, plot: Rect) -> bool {
|
||||
let near = |a: Point, b: Point| (a - b).hypot() <= EDGE;
|
||||
let on = |s: &PathSeg, a: Point, b: Point| match s {
|
||||
PathSeg::Line(l) => (near(l.p0, a) && near(l.p1, b)) || (near(l.p0, b) && near(l.p1, a)),
|
||||
_ => false,
|
||||
};
|
||||
let (top_left, bottom_left, bottom_right) = (Point::new(plot.x0, plot.y0), Point::new(plot.x0, plot.y1), Point::new(plot.x1, plot.y1));
|
||||
let (mut left, mut bottom) = (false, false);
|
||||
let mut stack = vec![art];
|
||||
while let Some(n) = stack.pop() {
|
||||
if !n.visible || n.opacity <= 0.0 || (left && bottom) {
|
||||
continue;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Group { children, clip: false } => stack.extend(children.iter().map(|c| &**c)),
|
||||
NodeKind::Path { path, .. } if plain_paint(n).is_some_and(|(_, line)| line.is_some()) => {
|
||||
for s in path.to_bezpath().segments() {
|
||||
left |= on(&s, top_left, bottom_left);
|
||||
bottom |= on(&s, bottom_left, bottom_right);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
left && bottom
|
||||
}
|
||||
|
||||
/// Graphs built as the graph tools draw them, for the writers' tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod sample {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Document, GraphKind, GraphSpec, Node, PieSort, TickLength, ValueAxisSide};
|
||||
use vectorcraft_geom::{PathData, Point, Rect, shapes};
|
||||
|
||||
use super::Appearance;
|
||||
|
||||
/// A graph of kind `kind`: series East and West, categories Q1 to Q3, West blank in Q2, on the
|
||||
/// plot 100 100 300 250.
|
||||
pub(crate) fn spec(kind: GraphKind) -> GraphSpec {
|
||||
let mut g = GraphSpec {
|
||||
kind,
|
||||
rect: Rect::new(100.0, 100.0, 300.0, 250.0),
|
||||
series: vec!["East".into(), "West".into()],
|
||||
categories: vec!["Q1".into(), "Q2".into(), "Q3".into()],
|
||||
..GraphSpec::default()
|
||||
};
|
||||
g.set_cells(vec![vec![Some(5.0), Some(3.0)], vec![Some(7.0), None], vec![Some(4.0), Some(6.0)]]);
|
||||
g
|
||||
}
|
||||
|
||||
fn path(doc: &mut Document, p: PathData, fill: Paint, stroke: Paint) -> Arc<Node> {
|
||||
Arc::new(Node::path(doc.alloc_id(), p, Appearance::basic(fill, stroke, 1.0)))
|
||||
}
|
||||
|
||||
/// Graph `g` drawn as a group: a group of each series' marks (a line graph's series as an unfilled
|
||||
/// line, the others' as filled columns), its axes on the plot's left and bottom edges, and a
|
||||
/// legend of a swatch for each series; its series groups named after the series.
|
||||
pub(crate) fn graph(doc: &mut Document, g: GraphSpec) -> Node {
|
||||
let r = g.rect;
|
||||
let cells = g.cells();
|
||||
let width = cells.first().map_or(0, Vec::len);
|
||||
let mut members = vec![];
|
||||
let axes = vec![
|
||||
path(doc, shapes::line(Point::new(r.x0, r.y0), Point::new(r.x0, r.y1)), Paint::None, Paint::solid(Color::BLACK)),
|
||||
path(doc, shapes::line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)), Paint::None, Paint::solid(Color::BLACK)),
|
||||
];
|
||||
members.push(Arc::new(Node::group(doc.alloc_id(), axes)));
|
||||
for s in 0..width {
|
||||
let gray = Paint::solid(Color::gray(0.2 + 0.3 * s as f32));
|
||||
let points: Vec<Point> = cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(c, row)| {
|
||||
row.get(s).copied().flatten().map(|v| Point::new(r.x0 + 20.0 + 60.0 * c as f64 + 15.0 * s as f64, r.y1 - v * 10.0))
|
||||
})
|
||||
.collect();
|
||||
let marks: Vec<Arc<Node>> = if g.kind == GraphKind::Line {
|
||||
let mut bp = kurbo::BezPath::new();
|
||||
for (i, p) in points.iter().enumerate() {
|
||||
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
|
||||
}
|
||||
vec![path(doc, PathData::from_bezpath(&bp), Paint::None, gray)]
|
||||
} else {
|
||||
points.iter().map(|p| path(doc, shapes::rectangle(Rect::new(p.x, p.y, p.x + 10.0, r.y1)), gray.clone(), Paint::None)).collect()
|
||||
};
|
||||
let mut series = Node::group(doc.alloc_id(), marks);
|
||||
series.name = Some(g.series.get(s).cloned().unwrap_or_else(|| format!("Series {}", s + 1)));
|
||||
series.extras_mut().series_index = Some(s as u32);
|
||||
members.push(Arc::new(series));
|
||||
}
|
||||
let swatches: Vec<Arc<Node>> = (0..width)
|
||||
.map(|s| {
|
||||
let y = r.y0 + 12.0 * s as f64;
|
||||
path(
|
||||
doc,
|
||||
shapes::rectangle(Rect::new(r.x1 + 10.0, y, r.x1 + 18.0, y + 8.0)),
|
||||
Paint::solid(Color::gray(0.2 + 0.3 * s as f32)),
|
||||
Paint::None,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
members.push(Arc::new(Node::group(doc.alloc_id(), swatches)));
|
||||
let mut n = Node::group(doc.alloc_id(), members);
|
||||
n.extras_mut().graph = Some(Box::new(g));
|
||||
n
|
||||
}
|
||||
|
||||
/// A 400 × 300 document holding `g` drawn by [`graph`] on its layer.
|
||||
pub(crate) fn document(g: GraphSpec) -> Document {
|
||||
let mut doc = Document::new(400.0, 300.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let n = graph(&mut doc, g);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// Graphs whose records read back whole: each type the records give, every setting they keep
|
||||
/// changed, labels with parentheses, backslashes and non-ASCII characters, a label of 255 bytes,
|
||||
/// and a table without labels.
|
||||
pub(crate) fn kept() -> Vec<(&'static str, Document)> {
|
||||
let mut cases = vec![
|
||||
("column", document(spec(GraphKind::Column))),
|
||||
("stacked column", document(spec(GraphKind::StackedColumn))),
|
||||
("line", document(spec(GraphKind::Line))),
|
||||
("area", document(spec(GraphKind::Area))),
|
||||
];
|
||||
let mut g = spec(GraphKind::Column);
|
||||
(g.drop_shadow, g.first_row_in_front, g.first_column_in_front, g.legend_across_top) = (true, true, true, true);
|
||||
(g.mark_points, g.connect_points, g.edge_to_edge) = (false, false, true);
|
||||
(g.column_width, g.cluster_width) = (120.0, 50.0);
|
||||
(g.tick_length, g.tick_marks, g.ticks, g.axis_min, g.axis_max) = (TickLength::Full, 3, 4, Some(0.0), Some(20.0));
|
||||
(g.right_tick_length, g.right_tick_marks, g.right_ticks, g.right_axis_min, g.right_axis_max) = (TickLength::None, 2, 5, Some(1.0), Some(6.0));
|
||||
(g.category_tick_length, g.category_tick_marks, g.ticks_between_labels) = (TickLength::Short, 2, true);
|
||||
(g.prefix, g.suffix, g.right_prefix, g.right_suffix) = ("($".into(), "\\)".into(), "é".into(), " u".into());
|
||||
cases.push(("settings", document(g)));
|
||||
let mut g = spec(GraphKind::Column);
|
||||
g.series = vec!["A (1)".into(), "B\\2".into()];
|
||||
g.categories = vec![String::new(), "naïve".into(), "a".repeat(255)];
|
||||
g.set_cells(vec![vec![Some(1.0 / 3.0), Some(1e-7)], vec![None, Some(2.5)], vec![Some(12.0), None]]);
|
||||
cases.push(("labels", document(g)));
|
||||
let mut g = spec(GraphKind::Column);
|
||||
(g.series, g.categories) = (vec![], vec![]);
|
||||
cases.push(("no labels", document(g)));
|
||||
cases
|
||||
}
|
||||
|
||||
/// Graphs the records can't give as they are, which are written as their art: the five other
|
||||
/// types, settings the reader doesn't keep, a table it can't hold, and art it can't attach the
|
||||
/// graph to.
|
||||
pub(crate) fn unkept() -> Vec<(&'static str, Document)> {
|
||||
let with = |f: &dyn Fn(&mut GraphSpec)| {
|
||||
let mut g = spec(GraphKind::Column);
|
||||
f(&mut g);
|
||||
document(g)
|
||||
};
|
||||
let mut cases: Vec<(&'static str, Document)> = [GraphKind::Bar, GraphKind::StackedBar, GraphKind::Scatter, GraphKind::Pie, GraphKind::Radar]
|
||||
.into_iter()
|
||||
.map(|k| (k.id(), document(spec(k))))
|
||||
.collect();
|
||||
cases.extend([
|
||||
("no legend", with(&|g| g.legend = false)),
|
||||
("filled lines", with(&|g| (g.kind, g.filled_lines) = (GraphKind::Line, true))),
|
||||
("a series of another type", with(&|g| g.series_kinds = vec![None, Some(GraphKind::Line)])),
|
||||
("a marker design", with(&|g| g.series_markers = vec![Some("Star".into())])),
|
||||
("a column design", with(&|g| g.series_columns = vec![Some(Default::default())])),
|
||||
("label formatting", with(&|g| g.label_styles.values = Some(Default::default()))),
|
||||
("a value axis on both sides", with(&|g| g.value_axis = ValueAxisSide::Both)),
|
||||
("a series on the right axis", with(&|g| g.right_series = vec![1])),
|
||||
("pie settings", with(&|g| g.pie_sort = PieSort::All)),
|
||||
("a manual axis without its values", with(&|g| g.ticks = 4)),
|
||||
("values without a tick count", with(&|g| (g.axis_min, g.axis_max) = (Some(0.0), Some(10.0)))),
|
||||
("101 divisions", with(&|g| (g.ticks, g.axis_min, g.axis_max) = (101, Some(0.0), Some(101.0)))),
|
||||
("too many tick marks", with(&|g| g.tick_marks = 21)),
|
||||
("a column width past 1000%", with(&|g| g.column_width = 1001.0)),
|
||||
("a long prefix", with(&|g| g.prefix = "0123456789".into())),
|
||||
("a line break in a label", with(&|g| g.series[0] = "East\nside".into())),
|
||||
("a label of 256 bytes", with(&|g| g.categories[0] = "a".repeat(256))),
|
||||
("a value that isn't finite", with(&|g| g.rows[0][0] = f64::NAN)),
|
||||
("only blank cells", with(&|g| g.set_cells(vec![vec![None, None]; 3]))),
|
||||
("fewer labels than series", with(&|g| g.series.truncate(1))),
|
||||
]);
|
||||
let drawn = |what: &'static str, f: &dyn Fn(&mut Vec<Arc<Node>>)| {
|
||||
let mut doc = Document::new(400.0, 300.0);
|
||||
let mut n = graph(&mut doc, spec(GraphKind::Column));
|
||||
if let Some(c) = n.children_mut() {
|
||||
f(c);
|
||||
}
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
(what, doc)
|
||||
};
|
||||
cases.extend([
|
||||
drawn("a series without its group", &|c| {
|
||||
c.remove(2);
|
||||
}),
|
||||
drawn("no axes", &|c| {
|
||||
c.remove(0);
|
||||
}),
|
||||
drawn("a hidden legend", &|c| {
|
||||
if let Some(l) = c.last_mut() {
|
||||
Arc::make_mut(l).visible = false;
|
||||
}
|
||||
}),
|
||||
drawn("a series drawn half opaque", &|c| Arc::make_mut(&mut c[1]).opacity = 0.5),
|
||||
drawn("a series mark with a gradient", &|c| {
|
||||
let g = vectorcraft_color::Gradient::new(
|
||||
vectorcraft_color::GradientKind::Linear,
|
||||
vec![vectorcraft_color::GradientStop::new(0.0, Color::BLACK), vectorcraft_color::GradientStop::new(1.0, Color::WHITE)],
|
||||
);
|
||||
let series = Arc::make_mut(&mut c[1]);
|
||||
if let Some(marks) = series.children_mut() {
|
||||
let mark = Arc::make_mut(&mut marks[0]);
|
||||
mark.appearance = Appearance::basic(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(g))), Paint::None, 0.0).into();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
cases
|
||||
}
|
||||
|
||||
/// The graphs in `doc`, each with the series index of its members.
|
||||
pub(crate) fn graphs(doc: &Document) -> Vec<(GraphSpec, Vec<Option<u32>>)> {
|
||||
let mut out = vec![];
|
||||
doc.walk(|n| {
|
||||
if let Some(g) = n.extras().graph.as_deref() {
|
||||
out.push((g.clone(), n.children().map(|c| c.iter().map(|c| c.extras().series_index).collect()).unwrap_or_default()));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `read` is the graph `written` was: the same data and settings, the plot in the same
|
||||
/// place; its series' paint is the art's and its bounds are the art's as read.
|
||||
pub(crate) fn same(written: &GraphSpec, read: &GraphSpec) -> bool {
|
||||
let near = |a: Rect, b: Rect| (a.x0 - b.x0).abs() + (a.y0 - b.y0).abs() + (a.x1 - b.x1).abs() + (a.y1 - b.y1).abs() < 1e-6;
|
||||
let plain = |g: &GraphSpec| GraphSpec {
|
||||
rect: Rect::ZERO,
|
||||
rows: vec![],
|
||||
blanks: vec![],
|
||||
series_paints: vec![],
|
||||
placed: None,
|
||||
placed_art: None,
|
||||
series_kinds: vec![],
|
||||
..g.clone()
|
||||
};
|
||||
near(written.rect, read.rect) && written.cells() == read.cells() && plain(written) == plain(read)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Numbers are written so they read back the same, small and large ones included.
|
||||
#[test]
|
||||
fn numbers_read_back_as_written() {
|
||||
for v in [0.0, 1.0, -2.5, 1.0 / 3.0, 1e-7, 123_456_789_012_345_678.0, f64::MAX, f64::MIN_POSITIVE] {
|
||||
assert_eq!(real(v).parse::<f64>().ok(), Some(v), "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph of `categories` rows of `series` values, drawn by [`sample::graph`].
|
||||
fn sized(categories: usize, series: usize) -> Node {
|
||||
let mut g = sample::spec(GraphKind::Column);
|
||||
(g.series, g.categories) = (vec![], vec![]);
|
||||
g.set_cells(vec![vec![Some(1.0); series]; categories]);
|
||||
sample::graph(&mut vectorcraft_doc::Document::new(400.0, 300.0), g)
|
||||
}
|
||||
|
||||
/// The table the reader holds: up to 10,000 categories and 256 series; past them, the graph is
|
||||
/// its art.
|
||||
#[test]
|
||||
fn tables_up_to_the_readers_limits_are_written() {
|
||||
assert!(records(&sized(MAX_GRAPH_CATEGORIES, 1), Affine::IDENTITY).is_some_and(|r| r.head.contains("%_10000 1 0 0 Gz\n")));
|
||||
assert!(records(&sized(MAX_GRAPH_CATEGORIES + 1, 1), Affine::IDENTITY).is_none());
|
||||
assert!(records(&sized(1, MAX_GRAPH_SERIES), Affine::IDENTITY).is_some_and(|r| r.tags.len() == MAX_GRAPH_SERIES));
|
||||
assert!(records(&sized(1, MAX_GRAPH_SERIES + 1), Affine::IDENTITY).is_none());
|
||||
}
|
||||
|
||||
/// Tick-mark counts of 0, which mean one tick mark as 1 does, are written as 1, the count the
|
||||
/// reader reads back.
|
||||
#[test]
|
||||
fn tick_mark_counts_of_0_are_written_as_1() {
|
||||
let mut g = sample::spec(GraphKind::Column);
|
||||
(g.tick_marks, g.right_tick_marks, g.category_tick_marks) = (0, 0, 0);
|
||||
let n = sample::graph(&mut vectorcraft_doc::Document::new(400.0, 300.0), g);
|
||||
let head = records(&n, Affine::IDENTITY).map(|r| r.head).unwrap_or_default();
|
||||
let marks: Vec<&str> = head.lines().filter(|l| l.ends_with(" GA")).map(|l| l.split(' ').nth(5).unwrap_or("")).collect();
|
||||
assert_eq!(marks, ["1", "1", "1"], "{head}");
|
||||
}
|
||||
|
||||
/// A table's cells go fifteen to a record, with one string at most in each.
|
||||
#[test]
|
||||
fn cells_go_fifteen_to_a_record_with_one_string_each() {
|
||||
let mut out = String::new();
|
||||
let mut t = Table::new(&mut out);
|
||||
t.text("a");
|
||||
t.text("b");
|
||||
for v in 0..16 {
|
||||
t.num(f64::from(v));
|
||||
}
|
||||
t.finish();
|
||||
let records: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(records[0], "%_(a) Gc");
|
||||
assert_eq!(records[1], "%_(b) 0 1 2 3 4 5 6 7 8 9 10 11 12 13 Gc");
|
||||
assert_eq!(records[2], "%_14 15 Gc");
|
||||
}
|
||||
}
|
||||
@@ -10329,11 +10329,14 @@ fn a_brushed_path_with_samples_of_the_pen_keeps_its_drawn_look() {
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the drawn look: {a:#?}");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
|
||||
// The samples belong to the path before them: a path after them is read as it was.
|
||||
let after = format!("{path}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0));
|
||||
// The samples belong to the path before them: a path after them with the same brush and no
|
||||
// samples of its own comes in brushed.
|
||||
let after = format!("{path}{}", brushed_path());
|
||||
let a = art(&read_ok(&format!("{def}{style}{}", layer(&after))).doc);
|
||||
assert_eq!(a.len(), 2, "{a:#?}");
|
||||
assert!(matches!(a[1].kind, NodeKind::Path { .. }) && a[1].appearance.fill().is_some(), "{a:#?}");
|
||||
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the sampled path's drawn look: {a:#?}");
|
||||
assert!(matches!(a[1].kind, NodeKind::Path { .. }), "the path after the samples, brushed: {a:#?}");
|
||||
assert_eq!(a[1].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Six D"), "{a:#?}");
|
||||
// Samples with no object before them in the layer mark nothing: the path after them is read.
|
||||
let first = format!("{PEN_SAMPLES}{}", brushed_path());
|
||||
let a = art(&read_ok(&format!("{def}{style}{}", layer(&first))).doc);
|
||||
|
||||
@@ -43,13 +43,17 @@
|
||||
//! PostScript names in `%%DocumentFonts` and `%%IncludeFont` (§3.1) and re-encoded to
|
||||
//! Windows-1252 (`TE`, then `%AI3_BeginEncoding: _name name`, `TZ`, §3.3), the 8-bit encoding
|
||||
//! strings are in.
|
||||
//! - Graphs (§17): a graph whose records VectorCraft's reader reads back as the same graph
|
||||
//! (`crate::ai_graph`) as `%_Gs`, its functional records, its group with each series' group
|
||||
//! followed by `%_2 column 0 0 Go`, and `%_GS`.
|
||||
//!
|
||||
//! The format has one artboard, no transparency, sublayers, object names, hidden objects (only
|
||||
//! layers hide), live effects, symbols or appearances with several fills and strokes. Callers write
|
||||
//! one file per artboard and expand appearances first; what is left is written as the format can
|
||||
//! hold it, each loss a warning: transparent objects are written opaque, sublayers become groups,
|
||||
//! live objects and symbols their art, strokes a plain line can't draw their outlines, pattern
|
||||
//! fills their tiles clipped to the shape, freeform gradients their average colour, elliptical
|
||||
//! live objects and symbols their art, other graphs their art, strokes a plain line can't draw
|
||||
//! their outlines, pattern fills their tiles clipped to the shape, freeform gradients their average
|
||||
//! colour, elliptical
|
||||
//! radial gradients circular (the gradient matrix is relative to a canvas the specification
|
||||
//! doesn't size), transparent image pixels over white. Hidden objects go to a hidden layer of
|
||||
//! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden
|
||||
|
||||
@@ -137,7 +137,11 @@ pub(super) fn lost_attributes(t: &TextObject) -> bool {
|
||||
|| !t.fractional_widths
|
||||
|| (matches!(t.kind, TextKind::Area { .. }) && t.area != AreaOptions::default())
|
||||
|| (matches!(t.kind, TextKind::OnPath { .. })
|
||||
&& (t.path_effect != PathEffect::default() || t.path_align != PathAlign::default() || t.path_spacing != 0.0 || end_bracket(t)))
|
||||
&& (t.path_effect != PathEffect::default()
|
||||
|| t.path_align != PathAlign::default()
|
||||
|| t.path_spacing != 0.0
|
||||
|| end_bracket(t)
|
||||
|| t.kind.starts_before_path()))
|
||||
|| several_paths(&t.kind)
|
||||
}
|
||||
|
||||
|
||||
@@ -1560,6 +1560,22 @@ fn type_on_a_path_alignment_and_end_bracket_are_warned_as_left_out() {
|
||||
assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(true), start: 0.7, end: Some(0.5) }));
|
||||
// An open path's end bracket before its start bracket.
|
||||
assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: 0.5, end: Some(0.3) }));
|
||||
// Type that starts before its open path: the text path holds a start on the path only.
|
||||
assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: -0.25, end: None }));
|
||||
assert!(!warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: 0.0, end: None }));
|
||||
// It is written starting at the path's start: the same text object as a start of 0.
|
||||
let written = |start: f64| {
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Along", style(12.0, Paint::solid(Color::BLACK)));
|
||||
t.kind = TextKind::OnPath { path: along(false), start, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
write(&doc).bytes
|
||||
};
|
||||
assert_eq!(written(-0.25), written(0.0));
|
||||
assert_ne!(written(0.25), written(0.0));
|
||||
let two = |first_closed: bool| {
|
||||
let mut path = along(first_closed);
|
||||
let pts = [(150.0, 100.0).into(), (250.0, 100.0).into()];
|
||||
@@ -2157,3 +2173,64 @@ fn a_stained_instance_is_written_in_its_stained_color() {
|
||||
let text = String::from_utf8(write(&doc).bytes).unwrap();
|
||||
assert!(text.contains("0 0 1 Xa") && !text.contains("1 0 0 Xa"), "{text}");
|
||||
}
|
||||
|
||||
/// A graph whose records read back whole is written with them (§17), and opens again as the same
|
||||
/// live graph: its table, type and settings, its series' groups tagged in order, without a warning
|
||||
/// about graphs.
|
||||
#[test]
|
||||
fn graphs_are_written_with_their_records_and_open_live() {
|
||||
use crate::ai_graph::sample;
|
||||
for (what, doc) in sample::kept() {
|
||||
let out = write(&doc);
|
||||
assert!(!out.warnings.iter().any(|w| w == GRAPHS), "{what}: {:?}", out.warnings);
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
assert_eq!((text.matches("%_Gs\n").count(), text.matches("%_GS\n").count()), (1, 1), "{what}: {text}");
|
||||
let read = crate::import(&out.bytes).unwrap();
|
||||
let written = sample::graphs(&doc);
|
||||
let graphs = sample::graphs(&read.document);
|
||||
assert_eq!(graphs.len(), 1, "{what}: {:?}", read.warnings);
|
||||
assert!(sample::same(&written[0].0, &graphs[0].0), "{what}: {:#?}\n{:#?}", written[0].0, graphs[0].0);
|
||||
assert_eq!(graphs[0].1, written[0].1, "{what}: the members and their series");
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph inside a group is written with its records too, and opens on its own group.
|
||||
#[test]
|
||||
fn a_graph_in_a_group_is_written_with_its_records() {
|
||||
use crate::ai_graph::sample;
|
||||
let mut doc = Document::new(400.0, 300.0);
|
||||
let graph = sample::graph(&mut doc, sample::spec(vectorcraft_doc::GraphKind::Column));
|
||||
let frame = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), red(), Paint::None);
|
||||
let outer = Node::group(doc.alloc_id(), vec![Arc::new(frame), Arc::new(graph)]);
|
||||
let l = doc.layers[0].id;
|
||||
doc.insert(Some(l), 0, outer).unwrap();
|
||||
let out = write(&doc);
|
||||
assert!(!out.warnings.iter().any(|w| w == GRAPHS), "{:?}", out.warnings);
|
||||
let read = crate::import(&out.bytes).unwrap();
|
||||
let graphs = sample::graphs(&read.document);
|
||||
assert_eq!(graphs.len(), 1, "{:?}", read.warnings);
|
||||
assert!(sample::same(&sample::graphs(&doc)[0].0, &graphs[0].0));
|
||||
let layer = children(&read.document.layers[0]);
|
||||
assert!(layer[0].extras().graph.is_none() && layer[0].children().is_some_and(|c| c.len() == 2), "{layer:#?}");
|
||||
}
|
||||
|
||||
/// A graph whose records wouldn't read back as it is is written as its art, without records, with
|
||||
/// the warning, and opens as that art.
|
||||
#[test]
|
||||
fn graphs_the_records_cant_keep_are_written_as_their_art() {
|
||||
use crate::ai_graph::sample;
|
||||
for (what, doc) in sample::unkept() {
|
||||
let out = export_legacy_ai(&doc, &LegacyAiOptions::default()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == GRAPHS), "{what}: {:?}", out.warnings);
|
||||
let text = String::from_utf8_lossy(&out.bytes).into_owned();
|
||||
assert!(!text.contains("%_Gs") && !text.contains(" Go\n") && !text.contains("%_GS"), "{what}: {text}");
|
||||
let read = crate::import(&out.bytes).unwrap();
|
||||
assert!(sample::graphs(&read.document).is_empty(), "{what}");
|
||||
let paths = |d: &Document| {
|
||||
let mut count = 0;
|
||||
d.walk(|n| count += usize::from(matches!(n.kind, NodeKind::Path { .. })));
|
||||
count
|
||||
};
|
||||
assert_eq!(paths(&read.document), paths(&doc), "{what}: the graph's art is kept");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ pub(super) struct Writer<'a> {
|
||||
last_image: Option<(ImageBlob, Option<Arc<image::DynamicImage>>)>,
|
||||
/// The images written whole so far, by the address of their data (which each entry keeps).
|
||||
whole_images: std::collections::HashMap<usize, Arc<Vec<u8>>>,
|
||||
/// Writing a graph's art with its records: a graph inside it is written as its art.
|
||||
in_graph: bool,
|
||||
}
|
||||
|
||||
impl<'a> Writer<'a> {
|
||||
@@ -78,6 +80,7 @@ impl<'a> Writer<'a> {
|
||||
clips: vec![],
|
||||
last_image: None,
|
||||
whole_images: std::collections::HashMap::new(),
|
||||
in_graph: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +267,12 @@ impl<'a> Writer<'a> {
|
||||
if n.name.as_deref().is_some_and(|s| !s.is_empty()) && !n.is_layer() {
|
||||
self.warn(NAMES);
|
||||
}
|
||||
if n.extras().graph.is_some() {
|
||||
// A graph on the layers keeps its data and settings when its records read back whole.
|
||||
let graph = match n.extras().graph.is_some() {
|
||||
true if self.nest == 0 && !self.in_graph => crate::ai_graph::records(n, self.to_art),
|
||||
_ => None,
|
||||
};
|
||||
if n.extras().graph.is_some() && graph.is_none() {
|
||||
self.warn(GRAPHS);
|
||||
}
|
||||
let locked = locked || n.locked;
|
||||
@@ -273,7 +281,10 @@ impl<'a> Writer<'a> {
|
||||
self.warn(SUBLAYERS);
|
||||
self.group(children, *clip, locked);
|
||||
}
|
||||
NodeKind::Group { children, clip } => self.group(children, *clip, locked),
|
||||
NodeKind::Group { children, clip } => match &graph {
|
||||
Some(records) => self.graph(children, locked, records),
|
||||
None => self.group(children, *clip, locked),
|
||||
},
|
||||
NodeKind::Path { path, rule, guide, .. } => {
|
||||
self.lock(locked);
|
||||
if *guide {
|
||||
@@ -352,6 +363,25 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph's group (`u` … `U`) between its records (§17): `%_Gs` and its functional records
|
||||
/// before it, `%_GS` after it, and each series' group followed by its `Go` tag.
|
||||
fn graph(&mut self, children: &[Arc<Node>], locked: bool, records: &crate::ai_graph::Records) {
|
||||
self.lock(locked);
|
||||
self.body.push_str(&records.head);
|
||||
self.line("u");
|
||||
self.in_graph = true;
|
||||
for c in children {
|
||||
self.node(c, locked);
|
||||
if let Some(tag) = records.tags.get(&c.id) {
|
||||
self.line(tag);
|
||||
}
|
||||
}
|
||||
self.in_graph = false;
|
||||
self.lock(locked);
|
||||
self.line("U");
|
||||
self.line(crate::ai_graph::END);
|
||||
}
|
||||
|
||||
/// A clipping group: its first member's outline clips the others (`h W n`). That member's fills
|
||||
/// paint behind the art, inside the clip, and its strokes over it, outside.
|
||||
fn clip_group(&mut self, children: &[Arc<Node>], locked: bool) {
|
||||
|
||||
@@ -31,9 +31,11 @@
|
||||
//! - `ai_editing`: the editing copy of the art a PDF-compatible `.ai` file carries
|
||||
//! ([`export_ai_editing`], [`ai_private_data`]).
|
||||
//! - `ai_ops`: the path and line style operators those two write.
|
||||
//! - `ai_graph`: the records of a live graph those two write around its group.
|
||||
//! - [`dcs`]: DCS files, an EPS file with one program per printing plate, read for placing.
|
||||
|
||||
mod ai_editing;
|
||||
mod ai_graph;
|
||||
mod ai_ops;
|
||||
pub mod dcs;
|
||||
mod import;
|
||||
|
||||
@@ -465,7 +465,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"transform",
|
||||
"Transform",
|
||||
"Move, rotate, scale, reflect or shear the selection (or `ids`); several may be combined and run in that order. Angles in degrees (counter-clockwise), scale in %. `origin` [x,y] defaults to the selection centre; `copy` transforms a copy.",
|
||||
"Move, rotate, scale, reflect or shear the selection (or `ids`); several may be combined and run in that order. Angles in degrees (counter-clockwise), scale in %. Scale and reflect act along the selection's bounding box, turned with rotated objects until Reset Bounding Box. `origin` [x,y] defaults to the selection centre; `copy` transforms a copy.",
|
||||
obj(
|
||||
json!({
|
||||
"ids": {"type": "array", "items": {"type": "integer"}},
|
||||
|
||||
@@ -800,7 +800,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"Cmd+Shift+F6",
|
||||
"{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view",
|
||||
),
|
||||
("tool.select", "Select Tool", "", "{tool: id} (see tools)"),
|
||||
("tool.select", "Select Tool", "", "{tool: id} (see tools) → {tool}; a tool that cannot be chosen returns the error"),
|
||||
(
|
||||
"tool.setOption",
|
||||
"Tool Option",
|
||||
@@ -811,7 +811,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"tool.toggleTouchMode",
|
||||
"Switch Selection and Drawing Modes",
|
||||
"",
|
||||
"{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)",
|
||||
"{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}; a tool that cannot be chosen returns the error and leaves the drawing tool as it was. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)",
|
||||
),
|
||||
(
|
||||
"effect.dialog",
|
||||
@@ -830,7 +830,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"ui.cropImage",
|
||||
"Crop Image",
|
||||
"",
|
||||
"{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box",
|
||||
"{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box. → {tool}; a tool that cannot be chosen returns the error",
|
||||
),
|
||||
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
|
||||
(
|
||||
@@ -1087,6 +1087,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{objects?: [paths|shapes|text|images|effects|groups|linkedFiles|guides], layers?: [locked|hidden]} set the Layers panel's Filter (By Object, By Layer): a row stays when it is of a kind in `objects` and in a state in `layers` (its own lock or eye; an empty list takes every row), or when a row inside it stays; Search All narrows the rows further. A key left out keeps its list. The filter lasts the session and does not change the document, its open rows or the selection → {objects, layers}",
|
||||
),
|
||||
(
|
||||
"ui.panelView",
|
||||
"Set Panel View",
|
||||
"",
|
||||
"{panel: swatches|graphicStyles|symbols, view?} set a panel's view, as the view items of its panel menu do: for swatches smallThumb|mediumThumb (the default)|largeThumb|smallList|largeList, for graphicStyles and symbols thumbnail (the default)|smallList|largeList; without `view`, answer the current one. The view lasts the session and does not change the document; an unknown panel or view is an error and changes nothing → {panel, view}",
|
||||
),
|
||||
(
|
||||
"ui.tileEdgeColor",
|
||||
"Tile Edge Color…",
|
||||
|
||||
@@ -246,7 +246,8 @@ impl Exporter<'_> {
|
||||
// Spaces are written too (text extraction needs them); control characters and soft
|
||||
// hyphens draw nothing.
|
||||
let blank = g.outline.elements().is_empty() && !coloured;
|
||||
if blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control()) {
|
||||
// Characters before the start of an open path are not drawn, spaces included.
|
||||
if g.off_path || (blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control())) {
|
||||
continue;
|
||||
}
|
||||
// The glyph at the font size of its vertical scale: `frame` maps that size's glyph
|
||||
|
||||
@@ -304,6 +304,26 @@ fn real_text_lands_within_half_a_point_of_the_outlines() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on an open path that starts before the path writes only the characters drawn on the path,
|
||||
/// as text and as outlines.
|
||||
#[test]
|
||||
fn type_starting_before_its_open_path_writes_only_what_is_drawn() {
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((20.0, 150.0));
|
||||
line.line_to((380.0, 150.0));
|
||||
let mut t = TextObject::point(Point::ZERO, "A C DEF", style(20.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
let laid = vectorcraft_text::layout(FontDb::global(), &t);
|
||||
// Four glyphs, two of them spaces, and a little more before the start: "DEF" is drawn.
|
||||
let lead: f64 = laid.glyphs.iter().take(4).map(|g| g.advance).sum::<f64>() + 1.0;
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -lead / 360.0, end: None };
|
||||
let d = doc(vec![t]);
|
||||
assert_eq!(texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)).concat(), "DEF");
|
||||
let outlined = ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines));
|
||||
assert!(outlined.x0 > 20.0 - 1e-6, "{outlined:?}");
|
||||
}
|
||||
|
||||
/// Area type 200 points wide holding `text`, its paragraphs in lists `lists`, with an 18 pt
|
||||
/// hanging indent.
|
||||
fn listed(text: &str, lists: &[Option<ParaList>]) -> TextObject {
|
||||
|
||||
@@ -117,6 +117,24 @@ fn variable_font_settings_are_not_highlighted_as_substituted_glyphs() {
|
||||
assert!(g.alternate_glyphs(&t).elements().is_empty());
|
||||
}
|
||||
|
||||
/// Type in a missing font that starts before its open path: only the characters drawn on the path
|
||||
/// are highlighted as substituted.
|
||||
#[test]
|
||||
fn characters_before_an_open_path_are_not_highlighted() {
|
||||
let _steady = fonts_steady();
|
||||
let style = CharStyle { size: 20.0, font_family: "No Such Family 5932".into(), ..Default::default() };
|
||||
let mut t = TextObject::point(Point::ZERO, "MMMM", style);
|
||||
let mut line = vectorcraft_geom::BezPath::new();
|
||||
line.move_to((0.0, 50.0));
|
||||
line.line_to((500.0, 50.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
let whole = text_geom(&t).substituted_fonts;
|
||||
assert!(!whole.elements().is_empty());
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.9, end: None };
|
||||
let g = text_geom(&t);
|
||||
assert!(g.all.elements().is_empty() && g.substituted_fonts.elements().is_empty(), "{:?}", g.substituted_fonts);
|
||||
}
|
||||
|
||||
/// Type in a composite font draws each class of character in its part's face, which is not a
|
||||
/// substitute: Document Setup's highlight of substituted glyphs leaves it alone.
|
||||
#[test]
|
||||
|
||||
@@ -183,7 +183,8 @@ pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeo
|
||||
let (mut substituted_fonts, mut substituted_glyphs, mut alternate_candidates) = (BezPath::new(), BezPath::new(), vec![]);
|
||||
let plain = t.plain_text();
|
||||
let (mut images, mut colours) = (vec![], vec![]);
|
||||
for g in &layout.glyphs {
|
||||
// Glyphs before the start of an open path draw nothing and have no highlight cells.
|
||||
for g in layout.glyphs.iter().filter(|g| !g.off_path) {
|
||||
push(&mut all, g.run, &g.outline);
|
||||
if let Some((image, xf)) = g.placed_image() {
|
||||
images.push((g.run, image.clone(), xf));
|
||||
|
||||
@@ -571,6 +571,9 @@ impl Writer<'_> {
|
||||
let base = self.char_props(&drawn.first_style(), space);
|
||||
let mut props = base.clone();
|
||||
let (from, to) = t.kind.path_span().unwrap_or((start, 1.0));
|
||||
// Type on an open path may start before the path: a negative `startOffset`, and a reader
|
||||
// does not draw the characters before the path's start, as the layout does not.
|
||||
let from = t.kind.path_text_start().unwrap_or(from);
|
||||
// The anchor sits where the layout aligns the line within its span.
|
||||
let at = match t.para_at(0).justify {
|
||||
Justify::Center | Justify::JustifyCenter => {
|
||||
|
||||
@@ -1473,16 +1473,26 @@ impl Ctx {
|
||||
let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0];
|
||||
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
|
||||
// On a path of one closed subpath, text that starts before the path's start or past its
|
||||
// end wraps around the path. Otherwise it starts at the path's start or end.
|
||||
// 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 wraps = path.subpaths.len() == 1 && path.is_closed();
|
||||
let open = !path.is_closed();
|
||||
let wrap = |s: f64| {
|
||||
// `rem_euclid` can round a tiny negative value up to 1.
|
||||
let s = s.rem_euclid(1.0);
|
||||
if s < 1.0 { s } else { 0.0 }
|
||||
};
|
||||
let start =
|
||||
Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| if wraps { wrap(s) } else { s.clamp(0.0, 1.0) });
|
||||
let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| {
|
||||
if wraps {
|
||||
wrap(s)
|
||||
} else if open {
|
||||
s.clamp(-vectorcraft_doc::PATH_LEAD_MAX, 1.0)
|
||||
} else {
|
||||
s.clamp(0.0, 1.0)
|
||||
}
|
||||
});
|
||||
obj.kind = TextKind::OnPath { path, start, end: None };
|
||||
obj.xf = Affine::IDENTITY;
|
||||
} else if vertical {
|
||||
|
||||
@@ -335,10 +335,12 @@ fn text_on_a_closed_path_wraps_past_the_path_start() {
|
||||
// A negative startOffset, in user units or as a percentage, counts back from the path's end.
|
||||
assert!(close(start_of(t[4]), 0.9, 1e-9), "{}", start_of(t[4]));
|
||||
assert!(close(start_of(t[5]), 0.9, 1e-9), "{}", start_of(t[5]));
|
||||
// A path of two closed subpaths, and an open path, do not wrap: the text starts at the path's
|
||||
// start or end.
|
||||
// A path of two closed subpaths does not wrap: the text starts at the path's start. An open
|
||||
// path does not wrap either: text anchored before its start starts there
|
||||
// (`text_on_an_open_path_starts_before_the_path`), and text offset past its end starts at
|
||||
// its end.
|
||||
assert_eq!(start_of(t[6]), 0.0);
|
||||
assert_eq!(start_of(t[7]), 0.0);
|
||||
assert!(close(start_of(t[7]), -width / 300.0, 1e-6), "{} {width}", start_of(t[7]));
|
||||
assert_eq!(start_of(t[8]), 1.0);
|
||||
|
||||
// Laid out, the end-anchored text draws every glyph up the left side and ends at the start
|
||||
@@ -372,6 +374,52 @@ fn text_on_a_closed_path_wraps_past_the_path_start() {
|
||||
assert!(lay.glyphs.iter().enumerate().all(|(i, g)| g.byte == i), "{:?}", lay.glyphs.iter().map(|g| g.byte).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_an_open_path_starts_before_the_path() {
|
||||
// An open path 300 long. Text anchored, or offset, before its start starts that far before it,
|
||||
// and only the characters whose middle is on the path are drawn; the text keeps every character.
|
||||
let d = open(
|
||||
r##"<path id="o" d="M50 50 H150 V150 H50" fill="none" stroke="black"/>
|
||||
<text font-size="20" text-anchor="end"><textPath href="#o">Wave</textPath></text>
|
||||
<text font-size="20" text-anchor="middle"><textPath href="#o">Wave</textPath></text>
|
||||
<text font-size="20"><textPath href="#o" startOffset="-20%">Wave</textPath></text>
|
||||
<text font-size="20"><textPath href="#o">Wave</textPath></text>"##,
|
||||
);
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 4);
|
||||
let start_of = |t: &TextObject| match t.kind {
|
||||
TextKind::OnPath { start, .. } => start,
|
||||
_ => panic!("{:?}", t.kind),
|
||||
};
|
||||
let whole = layout(FontDb::global(), t[3]);
|
||||
let width: f64 = whole.glyphs.iter().map(|g| g.advance).sum();
|
||||
assert!(width > 0.0 && width < 60.0, "{width}");
|
||||
assert!(close(start_of(t[0]), -width / 300.0, 1e-6), "{} {width}", start_of(t[0]));
|
||||
assert!(close(start_of(t[1]), -width / 600.0, 1e-6), "{} {width}", start_of(t[1]));
|
||||
assert!(close(start_of(t[2]), -0.2, 1e-9), "{}", start_of(t[2]));
|
||||
let drawn = |t: &TextObject| {
|
||||
let lay = layout(FontDb::global(), t);
|
||||
assert!(!lay.overflow && lay.glyphs.len() == 4, "{} {}", lay.overflow, lay.glyphs.len());
|
||||
assert_eq!(t.plain_text(), "Wave");
|
||||
lay.glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect::<Vec<_>>()
|
||||
};
|
||||
// Ending at the path's start: nothing is drawn.
|
||||
assert_eq!(drawn(t[0]), [false; 4]);
|
||||
// Centered on the path's start: the characters whose middle is past the start are drawn, the
|
||||
// last ones of the text.
|
||||
// (A hidden glyph has no advance: the advances are the ones the text takes on the path.)
|
||||
let middle = drawn(t[1]);
|
||||
let mut x = -width / 2.0;
|
||||
for (g, shown) in whole.glyphs.iter().zip(&middle) {
|
||||
assert_eq!(*shown, x + g.advance / 2.0 >= 0.0, "{x} {middle:?}");
|
||||
x += g.advance;
|
||||
}
|
||||
assert!(middle.iter().any(|d| *d) && !middle[0] && middle[3], "{middle:?}");
|
||||
// 60 before the start, more than the text's width: nothing is drawn.
|
||||
assert_eq!(drawn(t[2]), [false; 4]);
|
||||
assert_eq!(drawn(t[3]), [true; 4]);
|
||||
}
|
||||
|
||||
/// The unit vector along a glyph's baseline.
|
||||
fn baseline_dir(angle: f64) -> vectorcraft_geom::Vec2 {
|
||||
vectorcraft_geom::Vec2::new(angle.cos(), angle.sin())
|
||||
|
||||
@@ -478,6 +478,31 @@ fn aligned_text_on_path_anchors_within_its_span() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on an open path that starts before the path is written with a negative `startOffset` and
|
||||
/// reads back starting there, with the same characters drawn.
|
||||
#[test]
|
||||
fn text_on_an_open_path_starting_before_it_round_trips() {
|
||||
let mut line = vectorcraft_geom::BezPath::new();
|
||||
line.move_to((0.0, 100.0));
|
||||
line.line_to((200.0, 100.0));
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let mut t = TextObject::point(Point::ZERO, "Before", CharStyle { size: 14.0, ..CharStyle::default() });
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.1, end: None };
|
||||
let shown = |t: &TextObject| {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect::<Vec<_>>()
|
||||
};
|
||||
let before = shown(&t);
|
||||
assert!(before.contains(&true) && before.contains(&false), "{before:?}");
|
||||
let id = d.alloc_id();
|
||||
let svg = export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &ExportOptions::default());
|
||||
assert!(svg.contains("startOffset=\"-10%\""), "{svg}");
|
||||
let r = import(&svg).unwrap();
|
||||
let NodeKind::Text(t) = &art(&r)[0].kind else { panic!() };
|
||||
let TextKind::OnPath { start, .. } = &t.kind else { panic!("not type on a path: {:?}", t.kind) };
|
||||
assert!((start + 0.1).abs() < 1e-6, "start {start}");
|
||||
assert_eq!(shown(t), before);
|
||||
}
|
||||
|
||||
/// An end bracket, Align to Path or Spacing, which `<textPath>` can't express, keep their look as
|
||||
/// outlines (#429).
|
||||
#[test]
|
||||
|
||||
@@ -43,7 +43,7 @@ pub struct OtFeatures {
|
||||
pub swash: bool,
|
||||
/// The features of [`EXTRA_FEATURES`] turned on, one bit each (off by default): stylistic
|
||||
/// sets, alternates, titling, proportional and lining figures, superscript, subscript,
|
||||
/// numerators and denominators, `aalt`.
|
||||
/// numerators and denominators, `aalt`, H or V Style (`hkna`) and Roman Italics (`ital`).
|
||||
pub extra: u32,
|
||||
/// The value of each feature of [`EXTRA_FEATURES`] that is on, as a tag `salt=2` gives it: for
|
||||
/// a feature with alternates, which of them the font draws (the HarfBuzz feature value, 1 for
|
||||
@@ -64,13 +64,20 @@ pub const MAX_STYLE_FEATURES: usize = 256;
|
||||
/// Features beyond the named fields, off unless asked for, each bit `i` of [`OtFeatures::extra`]:
|
||||
/// stylistic sets `ss01`–`ss20`, stylistic alternates `salt`, titling `titl`, proportional `pnum`
|
||||
/// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr`,
|
||||
/// denominators `dnom` and access all alternates `aalt`.
|
||||
/// denominators `dnom`, access all alternates `aalt`, and the OpenType panel menu's H or V Style
|
||||
/// `hkna` (shaped as `vkna` in vertical type) and Roman Italics `ital`.
|
||||
/// <https://learn.microsoft.com/en-us/typography/opentype/spec/features_ae#tag-aalt>
|
||||
pub const EXTRA_FEATURES: [&str; 29] = [
|
||||
/// <https://learn.microsoft.com/en-us/typography/opentype/spec/features_fj#tag-hkna>
|
||||
/// <https://learn.microsoft.com/en-us/typography/opentype/spec/features_fj#tag-ital>
|
||||
pub const EXTRA_FEATURES: [&str; 31] = [
|
||||
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "ss11", "ss12", "ss13", "ss14", "ss15", "ss16", "ss17", "ss18",
|
||||
"ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", "aalt",
|
||||
"ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", "aalt", "hkna", "ital",
|
||||
];
|
||||
|
||||
/// The features of [`EXTRA_FEATURES`] that are only on or off: they substitute one form for each
|
||||
/// glyph, so they take no alternate's number (`hkna=2` is refused).
|
||||
const SWITCH_FEATURES: [&str; 2] = ["hkna", "ital"];
|
||||
|
||||
impl Default for OtFeatures {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -107,10 +114,11 @@ struct ParsedTag<'a> {
|
||||
value: u16,
|
||||
}
|
||||
|
||||
/// Can feature `name` take a value (`salt=2`)? The features of [`EXTRA_FEATURES`] and `swsh` can:
|
||||
/// each substitutes glyphs, and the value says which alternate.
|
||||
/// Can feature `name` take a value (`salt=2`)? The features of [`EXTRA_FEATURES`] but
|
||||
/// [`SWITCH_FEATURES`], and `swsh`, can: each substitutes glyphs, and the value says which
|
||||
/// alternate.
|
||||
fn takes_value(name: &str) -> bool {
|
||||
name == "swsh" || EXTRA_FEATURES.contains(&name)
|
||||
name == "swsh" || (EXTRA_FEATURES.contains(&name) && !SWITCH_FEATURES.contains(&name))
|
||||
}
|
||||
|
||||
/// Read a feature tag: `tag`, `+tag`, `-tag` or, for a feature that takes a value, `tag=N` (`N`
|
||||
@@ -316,6 +324,8 @@ impl OtFeatures {
|
||||
}
|
||||
for (i, tag) in EXTRA_FEATURES.iter().enumerate() {
|
||||
if let (true, Ok(t)) = (s.extra & (1 << i) != 0, <[u8; 4]>::try_from(tag.as_bytes())) {
|
||||
// H or V Style: the kana forms made for the writing direction, `vkna` in vertical type.
|
||||
let t = if self.vertical && &t == b"hkna" { *b"vkna" } else { t };
|
||||
v.push(f(&t, u32::from(s.extra_values.get(i).copied().unwrap_or(0).max(1))));
|
||||
}
|
||||
}
|
||||
@@ -434,6 +444,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// H or V Style (`hkna`) and Roman Italics (`ital`) are known, round-trip through the stored
|
||||
/// tags and reach the shaper: `hkna` as itself in horizontal type and as `vkna` in vertical
|
||||
/// type, `ital` in both. Neither takes an alternate's number.
|
||||
#[test]
|
||||
fn kana_style_and_roman_italics_are_kept_and_shaped() {
|
||||
for t in ["hkna", "-hkna", "+ital", "ital"] {
|
||||
assert!(OtFeatures::known_tag(t), "{t}");
|
||||
}
|
||||
for t in ["hkna=2", "ital=0", "vkna"] {
|
||||
assert!(!OtFeatures::known_tag(t), "{t}");
|
||||
}
|
||||
assert_eq!(OtFeatures::from_tags(["ital", "hkna", "smcp"]).to_tags(), ["smcp", "hkna", "ital"]);
|
||||
assert_eq!(OtFeatures::from_tags(["hkna", "ital", "-hkna"]).to_tags(), ["ital"]);
|
||||
let st = CharStyle { features: vec!["hkna".into(), "ital".into()], ..CharStyle::default() };
|
||||
let on = |vertical: bool| -> Vec<String> {
|
||||
OtFeatures { vertical, ..OtFeatures::default() }.resolve(&st).iter().filter(|f| f.value == 1).map(|f| f.tag.to_string()).collect()
|
||||
};
|
||||
assert_eq!(on(false), ["hkna", "ital"]);
|
||||
assert_eq!(on(true), ["vkna", "ital", "vert"]);
|
||||
let plain = |vertical: bool| OtFeatures { vertical, ..OtFeatures::default() }.resolve(&CharStyle::default());
|
||||
for vertical in [false, true] {
|
||||
assert!(!plain(vertical).iter().any(|f| [b"hkna", b"vkna", b"ital"].iter().any(|t| f.tag == Tag::new(t))), "off by default");
|
||||
}
|
||||
}
|
||||
|
||||
/// `changes` lists the features a change turns on and off and leaves out the ones it keeps,
|
||||
/// and applying them to any set makes the same change.
|
||||
#[test]
|
||||
|
||||
@@ -626,6 +626,7 @@ impl Ctx<'_> {
|
||||
width_start,
|
||||
warichu: g.wari.map(|w| w.start),
|
||||
warichu_line: g.wari.map_or(0.0, |w| w.across),
|
||||
off_path: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -380,6 +380,35 @@ pub(crate) fn layout_trying_each_start(db: &crate::fontdb::FontDb, t: &TextObjec
|
||||
l
|
||||
}
|
||||
|
||||
/// For type that starts before its open path `ap` (`lead`), a glyph whose middle is `mid` along the
|
||||
/// path and falls before the path's start is not drawn, as SVG 2 lays out text on a path
|
||||
/// (https://www.w3.org/TR/SVG2/text.html#TextLayoutPath): glyph `at` keeps its place in the layout
|
||||
/// for the caret, with no length at the path's start (moved off the path by `rise`, as Align to Path
|
||||
/// moves the glyph), and draws nothing ([`crate::PositionedGlyph::off_path`]). True when it is hidden.
|
||||
fn hide_before_open_path(cx: &mut Ctx<'_>, ap: &ArcPath, lead: bool, at: usize, mid: f64, rise: f64) -> bool {
|
||||
if !lead || ap.is_closed() || mid >= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let (p, dir) = ap.at(0.0);
|
||||
let start = p + Vec2::new(-dir.y, dir.x) * rise;
|
||||
if let Some(glyph) = cx.out.glyphs.get_mut(at) {
|
||||
glyph.origin = start;
|
||||
glyph.advance = 0.0;
|
||||
glyph.width = 0.0;
|
||||
glyph.width_start = 0.0;
|
||||
glyph.outline = BezPath::new();
|
||||
glyph.image = None;
|
||||
glyph.colour = None;
|
||||
// A ligature's caret stops stay, at the path's start with the glyph.
|
||||
for c in &mut glyph.carets {
|
||||
c.1 = 0.0;
|
||||
}
|
||||
glyph.off_path = true;
|
||||
}
|
||||
cx.out.inlines.retain(|i| i.glyph != at);
|
||||
true
|
||||
}
|
||||
|
||||
/// Lay type on a path out along `path` (text space), between its brackets.
|
||||
pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &PathData) {
|
||||
cx.out.on_path = true;
|
||||
@@ -453,6 +482,11 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject,
|
||||
}
|
||||
let centre = path.bounds().unwrap_or_default().center();
|
||||
let (from, to) = t.kind.path_span().unwrap_or((0.0, 1.0));
|
||||
// On an open path the text may start before the path's start: the span runs from there, so
|
||||
// Dynamic Text fills it to the end bracket, and the drawn text runs from the path's start to the
|
||||
// end bracket.
|
||||
let from = t.kind.path_text_start().unwrap_or(from);
|
||||
let lead = t.kind.starts_before_path();
|
||||
let s_start = from * ap.len();
|
||||
let avail = (to - from) * ap.len();
|
||||
let spacing = if t.path_spacing.is_finite() { t.path_spacing } else { 0.0 };
|
||||
@@ -491,7 +525,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(l.min(s_start), r.min((ap.len() - s_start - avail).max(0.0)))
|
||||
(l.min(s_start.max(0.0)), r.min((ap.len() - s_start - avail).max(0.0)))
|
||||
};
|
||||
(l, r, hung_r)
|
||||
};
|
||||
@@ -673,10 +707,12 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject,
|
||||
m.tab = at;
|
||||
}
|
||||
}
|
||||
let at = cx.out.glyphs.len();
|
||||
cx.emit(g, pre, origin, angle, g.adv, 0);
|
||||
let hidden = hide_before_open_path(cx, &ap, lead, at, s + step * 0.5, rise);
|
||||
// A glyph of a script whose letters join is bent along a curved path (Rainbow), so its
|
||||
// joining strokes meet its neighbours' instead of standing off the curve.
|
||||
if t.path_effect == PathEffect::Rainbow && g.cursive && !cx.vertical && g.inline.is_none() {
|
||||
if !hidden && t.path_effect == PathEffect::Rainbow && g.cursive && !cx.vertical && g.inline.is_none() {
|
||||
let bend = GlyphBend { s, step, advance: g.adv, rise };
|
||||
if let Some(last) = cx.out.glyphs.last_mut()
|
||||
&& last.image.is_none()
|
||||
|
||||
@@ -379,5 +379,6 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi
|
||||
width_start: 0.0,
|
||||
warichu: None,
|
||||
warichu_line: 0.0,
|
||||
off_path: false,
|
||||
}
|
||||
}
|
||||
|
||||
+13
-3
@@ -188,6 +188,12 @@ pub struct PositionedGlyph {
|
||||
/// line's baseline before Character Alignment moves the glyph (y down). It names the block
|
||||
/// line whatever the alignment of each character on it. 0 for any other glyph.
|
||||
pub warichu_line: f64,
|
||||
/// Type on an open path that starts before the path: the glyph's middle falls before the
|
||||
/// path's start, so it draws nothing (its `outline` is empty and it has no image or color
|
||||
/// art), hit testing and Touch Type skip it while a glyph is drawn, and exports leave it out.
|
||||
/// It stays in the layout for the caret, at the path's start with no advance or width, and its
|
||||
/// ligature carets stand there too.
|
||||
pub off_path: bool,
|
||||
}
|
||||
|
||||
/// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its
|
||||
@@ -600,11 +606,15 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
|
||||
.map(|(_, d)| (p - (g.origin + along * if g.rtl { g.advance - d } else { d })).hypot2())
|
||||
.fold(mid, f64::min)
|
||||
};
|
||||
let best = layout.glyphs.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b)));
|
||||
// Glyphs before the start of an open path draw nothing: a click goes to a drawn one when
|
||||
// there is one.
|
||||
let drawn = layout.glyphs.iter().any(|g| !g.off_path);
|
||||
let best = layout.glyphs.iter().enumerate().filter(|(_, g)| !(drawn && g.off_path)).min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b)));
|
||||
let Some((i, g)) = best else { return layout.lines.first().map_or(0, |l| l.start) };
|
||||
// `first` and `last` are the first and last glyphs of the cluster. A click before the middle of
|
||||
// the cluster, measured along the first glyph's direction, goes to the cluster's start.
|
||||
let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len;
|
||||
// the cluster, measured along the first glyph's direction, goes to the cluster's start. A
|
||||
// cluster's glyphs before an open path's start are left out when one of its glyphs is drawn.
|
||||
let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len && !(drawn && o.off_path);
|
||||
let first = layout.glyphs[..=i].iter().rev().take_while(same).last().unwrap_or(g);
|
||||
let last = layout.glyphs[i..].iter().take_while(same).last().unwrap_or(g);
|
||||
let along = dir(first.angle);
|
||||
|
||||
@@ -635,6 +635,85 @@ fn on_path_placement() {
|
||||
assert_eq!(hit_byte(&l, mid), 1);
|
||||
}
|
||||
|
||||
/// Type on an open path that starts before the path's start draws only the glyphs whose middle is
|
||||
/// on the path; the others stay in the layout, drawing nothing. On a closed path a start below 0 is
|
||||
/// the path's start, as before.
|
||||
#[test]
|
||||
fn on_path_open_start_before_the_path_hides_the_glyphs_before_it() {
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 50.0));
|
||||
line.line_to((500.0, 50.0));
|
||||
let mut t = point("MMMM", style(20.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
let whole = layout(db(), &t);
|
||||
let adv = whole.glyphs[0].advance;
|
||||
assert!(adv > 1.0 && whole.glyphs.iter().all(|g| !g.outline.elements().is_empty()));
|
||||
// 2.4 glyphs before the start: the first two are before it, and the third's middle is just past
|
||||
// the path's start, so it is drawn.
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -2.4 * adv / 500.0, end: None };
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.on_path && !l.overflow);
|
||||
assert_eq!(l.glyphs.len(), 4);
|
||||
let drawn: Vec<bool> = l.glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect();
|
||||
assert_eq!(drawn, [false, false, true, true]);
|
||||
assert_eq!(l.glyphs.iter().map(|g| g.off_path).collect::<Vec<_>>(), [true, true, false, false]);
|
||||
assert!(l.glyphs[..2].iter().all(|g| g.carets.iter().all(|c| c.1 == 0.0) && g.advance == 0.0 && g.width == 0.0));
|
||||
assert!(whole.glyphs.iter().all(|g| !g.off_path));
|
||||
// Type that starts on the path keeps drawing every glyph, even where negative tracking steps
|
||||
// its glyphs back before the path's start.
|
||||
let mut packed = t.clone();
|
||||
packed.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
packed.runs[0].style.tracking = -1000.0;
|
||||
let p = layout(db(), &packed);
|
||||
assert!(p.glyphs.len() == 4 && p.glyphs.iter().all(|g| !g.off_path && !g.outline.elements().is_empty()));
|
||||
assert!((l.glyphs[2].origin.x + 0.4 * adv).abs() < 1e-6, "{:?}", l.glyphs[2].origin);
|
||||
assert!((l.glyphs[3].origin.x - 0.6 * adv).abs() < 1e-6, "{:?}", l.glyphs[3].origin);
|
||||
assert_eq!(l.lines[0].end, 4);
|
||||
// A click at the path's start goes before the first drawn character, not into a hidden one, and
|
||||
// the caret in the hidden characters stands at the path's start.
|
||||
assert_eq!(hit_byte(&l, Point::new(0.0, 50.0)), 2);
|
||||
for b in [0, 1] {
|
||||
let (top, bottom) = caret_position(&l, b);
|
||||
assert!(top.x.abs() < 1e-6 && bottom.x.abs() < 1e-6, "{b}: {top:?} {bottom:?}");
|
||||
}
|
||||
// An All Caps ß is two glyphs. With the first before the path's start, a click is measured
|
||||
// against the drawn one: just past its middle, the caret goes after the character.
|
||||
let mut caps = point("ße", style(20.0));
|
||||
caps.runs[0].style.all_caps = true;
|
||||
caps.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
let s = layout(db(), &caps).glyphs[0].advance;
|
||||
caps.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -1.2 * s / 500.0, end: None };
|
||||
let c = layout(db(), &caps);
|
||||
assert!(c.glyphs[0].byte == 0 && c.glyphs[1].byte == 0 && c.glyphs[0].off_path && !c.glyphs[1].off_path);
|
||||
let mid = c.glyphs[1].origin.x + c.glyphs[1].advance / 2.0;
|
||||
assert_eq!(hit_byte(&c, Point::new(mid + 0.05 * s, 50.0)), 2);
|
||||
assert_eq!(hit_byte(&c, Point::new(mid - 0.05 * s, 50.0)), 0);
|
||||
// With Align to Path, the hidden characters' caret is moved off the path as the drawn ones are.
|
||||
let mut aligned = t.clone();
|
||||
aligned.path_align = vectorcraft_doc::PathAlign::Ascender;
|
||||
let a = layout(db(), &aligned);
|
||||
let rise = a.glyphs[2].origin.y - 50.0;
|
||||
assert!(rise.abs() > 1.0, "{rise}");
|
||||
for g in &a.glyphs[..2] {
|
||||
assert!(g.off_path && g.origin.x.abs() < 1e-6 && (g.origin.y - 50.0 - rise).abs() < 1e-6, "{:?}", g.origin);
|
||||
}
|
||||
// The whole text before the path: nothing is drawn, and nothing overflows.
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.9, end: None };
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow && l.glyphs.len() == 4 && l.glyphs.iter().all(|g| g.outline.elements().is_empty()));
|
||||
// A closed path starts the text at the path's start.
|
||||
let mut square = BezPath::new();
|
||||
square.move_to((0.0, 0.0));
|
||||
square.line_to((200.0, 0.0));
|
||||
square.line_to((200.0, 200.0));
|
||||
square.line_to((0.0, 200.0));
|
||||
square.close_path();
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&square), start: -0.1, end: None };
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.glyphs.iter().all(|g| !g.outline.elements().is_empty()));
|
||||
assert!(l.glyphs[0].origin.x.abs() < 1e-6 && l.glyphs[0].origin.y.abs() < 1e-6, "{:?}", l.glyphs[0].origin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_path_effects_orient_glyphs() {
|
||||
use vectorcraft_doc::PathEffect;
|
||||
|
||||
@@ -170,6 +170,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout
|
||||
width_start: 0.0,
|
||||
warichu: None,
|
||||
warichu_line: 0.0,
|
||||
off_path: false,
|
||||
});
|
||||
x += advance;
|
||||
}
|
||||
|
||||
@@ -649,6 +649,7 @@ fn duplicate_authored_glyphs_keep_occurrence_order() {
|
||||
width_start: 0.0,
|
||||
warichu: None,
|
||||
warichu_line: 0.0,
|
||||
off_path: false,
|
||||
};
|
||||
let before = TextLayout { glyphs: vec![glyph(0), glyph(1)], ..TextLayout::default() };
|
||||
assert_eq!(assert_kashida_ownership(&text, &before, &before, &red(), "duplicate glyphs", false), 0);
|
||||
|
||||
@@ -1564,9 +1564,9 @@ impl Tool for GestureTool {
|
||||
};
|
||||
let mut area = self.area.take().filter(|a| a.fits(key, &self.points)).unwrap_or_else(|| AreaPreview::new(key, self.points[0]));
|
||||
area.step(&self.points, pressures);
|
||||
for path in area.paths(&self.points) {
|
||||
o.push(Overlay::Path { path, color, width: 1.0, dashed: false });
|
||||
}
|
||||
let paths = area.paths(&self.points);
|
||||
o.extend(paths.settled.into_iter().map(|path| Overlay::SharedPath { path, color, width: 1.0 }));
|
||||
o.extend(paths.live.into_iter().map(|path| Overlay::Path { path, color, width: 1.0, dashed: false }));
|
||||
self.area_pending.set(area.pending(&self.points));
|
||||
self.area.set(Some(area));
|
||||
} else if let Some(pressures) = self.size_pressures() {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
//! most [`JOIN_PIECES_PER_CALL`] pieces' worth. The grid holds at most [`LISTINGS`] listings.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{Affine, BezPath, Line, ParamCurve, PathData, PathEl, PathSeg, Point, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
@@ -84,10 +85,22 @@ pub(super) struct AreaWork {
|
||||
pub joined: usize,
|
||||
}
|
||||
|
||||
/// The paths of the outline to show ([`AreaPreview::paths`]).
|
||||
pub(super) struct AreaPaths {
|
||||
/// The settled pieces' outlines, joined [`BLOCK_PIECES`] at a time: a block that did not change
|
||||
/// is the same shared path as in the call before.
|
||||
pub settled: Vec<Arc<BezPath>>,
|
||||
/// The growing last piece's outline and the thin line over the points not taken yet, drawn
|
||||
/// over the settled ones.
|
||||
pub live: Vec<BezPath>,
|
||||
}
|
||||
|
||||
/// The joined outline of up to [`BLOCK_PIECES`] settled pieces.
|
||||
#[derive(Default)]
|
||||
struct Block {
|
||||
path: BezPath,
|
||||
/// Shared with the paths [`AreaPreview::paths`] returned: a new one only when the block is
|
||||
/// joined again.
|
||||
path: Arc<BezPath>,
|
||||
/// A piece in it changed since `path` was joined.
|
||||
stale: bool,
|
||||
}
|
||||
@@ -227,8 +240,9 @@ impl AreaPreview {
|
||||
/// The paths to show: the settled pieces' outlines, joined [`BLOCK_PIECES`] at a time, the
|
||||
/// growing last piece's and a thin line over the points of `pts` not taken yet. The joined
|
||||
/// paths with a changed piece are joined again, newest first, at most
|
||||
/// [`JOIN_PIECES_PER_CALL`] pieces a call; the others show the path last joined.
|
||||
pub(super) fn paths(&mut self, pts: &[Point]) -> Vec<BezPath> {
|
||||
/// [`JOIN_PIECES_PER_CALL`] pieces a call; the others show the path last joined, the same
|
||||
/// shared path each call.
|
||||
pub(super) fn paths(&mut self, pts: &[Point]) -> AreaPaths {
|
||||
let settled = self.settled_count();
|
||||
for b in (0..self.blocks.len()).rev() {
|
||||
if self.work.joined >= JOIN_PIECES_PER_CALL {
|
||||
@@ -243,9 +257,10 @@ impl AreaPreview {
|
||||
joined.extend(p.shown.elements().iter().copied());
|
||||
}
|
||||
self.work.joined += pieces.len();
|
||||
self.blocks[b] = Block { path: joined, stale: false };
|
||||
self.blocks[b] = Block { path: Arc::new(joined), stale: false };
|
||||
}
|
||||
let mut out: Vec<BezPath> = self.blocks.iter().filter(|b| !b.path.elements().is_empty()).map(|b| b.path.clone()).collect();
|
||||
let settled_paths = self.blocks.iter().filter(|b| !b.path.elements().is_empty()).map(|b| Arc::clone(&b.path)).collect();
|
||||
let mut out = vec![];
|
||||
if let Some(p) = self.pieces.get(settled).filter(|p| !p.shown.elements().is_empty()) {
|
||||
out.push(p.shown.clone());
|
||||
}
|
||||
@@ -261,7 +276,7 @@ impl AreaPreview {
|
||||
}
|
||||
out.push(guide);
|
||||
}
|
||||
out
|
||||
AreaPaths { settled: settled_paths, live: out }
|
||||
}
|
||||
|
||||
/// How many pieces no longer grow: all of them but a last one with room for more segments.
|
||||
|
||||
@@ -2450,11 +2450,111 @@ fn the_blob_brush_and_the_eraser_outline_a_long_drag_a_bounded_amount_per_frame(
|
||||
}
|
||||
}
|
||||
|
||||
/// The settled paths of the outline of a long Blob Brush or Eraser drag (each the outline of up
|
||||
/// to `BLOCK_PIECES` pieces) come back from one call to the next, at any zoom, as the same shared
|
||||
/// paths, which the host draws again without copying or flattening them. A step of the drag
|
||||
/// replaces only the paths of the pieces near it.
|
||||
#[test]
|
||||
fn the_settled_outline_of_a_long_drag_is_the_same_shared_path_each_frame() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = ToolContext { precise_cursors: true, ..cx(&d, &s, &p) };
|
||||
let settled = |o: &[Overlay]| -> Vec<std::sync::Arc<BezPath>> {
|
||||
o.iter().filter_map(|o| if let Overlay::SharedPath { path, .. } = o { Some(std::sync::Arc::clone(path)) } else { None }).collect()
|
||||
};
|
||||
let kept = |was: &[std::sync::Arc<BezPath>], now: &[std::sync::Arc<BezPath>]| {
|
||||
now.iter().filter(|n| was.iter().any(|w| std::sync::Arc::ptr_eq(w, n))).count()
|
||||
};
|
||||
for (id, angle, roundness, pressure) in
|
||||
[("blobBrush", 0.0, 100.0, false), ("eraser", -60.0, 30.0, false), ("blobBrush", 0.0, 100.0, true), ("eraser", 45.0, 50.0, true)]
|
||||
{
|
||||
let mut t = GestureTool::new(id);
|
||||
t.set_option("size", &json!(10));
|
||||
t.set_option("angle", &json!(angle));
|
||||
t.set_option("roundness", &json!(roundness));
|
||||
if pressure {
|
||||
t.set_option("sizeMode", &json!("pressure"));
|
||||
t.set_option("sizeVariation", &json!(4));
|
||||
}
|
||||
let n = if pressure { 3000 } else { 6000 };
|
||||
long_area_drag(&mut t, &cx, n, |i| if pressure { 0.3 + 0.6 * ((i % 50) as f32 / 50.0) } else { 1.0 });
|
||||
drain_area(&t, &cx);
|
||||
let first = settled(&t.overlays(&cx));
|
||||
assert!(first.len() > 1, "{id}: {} settled paths", first.len());
|
||||
for zoom in [1.0, 4.0, 1.0] {
|
||||
let again = settled(&t.overlays(&ToolContext { zoom, ..cx }));
|
||||
assert_eq!(again.len(), first.len(), "{id}");
|
||||
assert_eq!(kept(&first, &again), first.len(), "{id}: at zoom {zoom}, every settled path is the one shown before");
|
||||
}
|
||||
// One step more along the drag: with Size set to Pressure each path holds 64 one-segment
|
||||
// pieces, part of one turn of the drag, and the paths far from the step stay the ones shown
|
||||
// before. (Without it a path holds 64 pieces of 32 segments, many turns round the circle.)
|
||||
if !pressure {
|
||||
continue;
|
||||
}
|
||||
let s = n as f64;
|
||||
let r = 150.0 + 100.0 * (s * 0.0071).sin();
|
||||
let (x, y) = (300.0 + r * (s * 0.04).cos(), 300.0 + r * (s * 0.04).sin());
|
||||
t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, x, y) });
|
||||
drain_area(&t, &cx);
|
||||
let after = settled(&t.overlays(&cx));
|
||||
let replaced = after.len() - kept(&first, &after);
|
||||
assert!((1..=8).contains(&replaced), "{id}: {replaced} of {} settled paths replaced", after.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline a Blob Brush or Eraser drag shows does not change what its release sends: the same
|
||||
/// drag sends the same command whether or not its outline was drawn along the way.
|
||||
#[test]
|
||||
fn drawing_the_outline_of_a_drag_does_not_change_what_its_release_sends() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = ToolContext { precise_cursors: true, ..cx(&d, &s, &p) };
|
||||
for (id, angle, roundness, pressure) in
|
||||
[("blobBrush", 0.0, 100.0, false), ("blobBrush", 30.0, 40.0, true), ("eraser", -60.0, 30.0, false), ("eraser", 45.0, 50.0, true)]
|
||||
{
|
||||
let release = |drawn: bool| {
|
||||
let mut t = GestureTool::new(id);
|
||||
t.set_option("size", &json!(10));
|
||||
t.set_option("angle", &json!(angle));
|
||||
t.set_option("roundness", &json!(roundness));
|
||||
if pressure {
|
||||
t.set_option("sizeMode", &json!("pressure"));
|
||||
t.set_option("sizeVariation", &json!(4));
|
||||
}
|
||||
for i in 0..800 {
|
||||
let s = i as f64;
|
||||
let r = 150.0 + 100.0 * (s * 0.0071).sin();
|
||||
let (x, y) = (300.0 + r * (s * 0.04).cos(), 300.0 + r * (s * 0.04).sin());
|
||||
let kind = if i == 0 { PointerKind::Down } else { PointerKind::Drag };
|
||||
let pressure = if pressure { 0.3 + 0.6 * ((i % 50) as f32 / 50.0) } else { 1.0 };
|
||||
t.pointer(&cx, &PointerEvent { pressure, ..PointerEvent::new(kind, x, y) });
|
||||
if drawn {
|
||||
t.overlays(&cx);
|
||||
}
|
||||
}
|
||||
if drawn {
|
||||
drain_area(&t, &cx);
|
||||
}
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 300.0))
|
||||
};
|
||||
let (drawn, not_drawn) = (release(true), release(false));
|
||||
assert!(matches!(drawn.first(), Some(Action::Exec(..))), "{id}: {drawn:?}");
|
||||
assert_eq!(drawn, not_drawn, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every flattened point of the paths `o` (the outline shown).
|
||||
fn shown_points(o: &[Overlay]) -> Vec<(Point, Point)> {
|
||||
let mut lines = vec![];
|
||||
for o in o {
|
||||
let Overlay::Path { path, .. } = o else { continue };
|
||||
let path = match o {
|
||||
Overlay::Path { path, .. } => path,
|
||||
Overlay::SharedPath { path, .. } => &**path,
|
||||
_ => continue,
|
||||
};
|
||||
let mut pen = Point::ZERO;
|
||||
let mut start = Point::ZERO;
|
||||
vectorcraft_geom::kurbo::flatten(path.elements().iter().copied(), 0.01, |el| match el {
|
||||
@@ -2541,7 +2641,11 @@ fn a_long_jump_in_a_blob_brush_drag_is_outlined_a_bounded_amount_at_a_time() {
|
||||
let o = t.overlays(&cx);
|
||||
let b = o
|
||||
.iter()
|
||||
.filter_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::Shape::bounding_box(path)) } else { None })
|
||||
.filter_map(|o| match o {
|
||||
Overlay::Path { path, .. } => Some(vectorcraft_geom::Shape::bounding_box(path)),
|
||||
Overlay::SharedPath { path, .. } => Some(vectorcraft_geom::Shape::bounding_box(&**path)),
|
||||
_ => None,
|
||||
})
|
||||
.reduce(|a, b| a.union(b));
|
||||
let Some(b) = b else { panic!("{o:?}") };
|
||||
assert!((b.x0 + 0.5).abs() < 0.01 && (b.x1 - 20_000.5).abs() < 0.01, "{b:?}");
|
||||
|
||||
@@ -71,6 +71,10 @@ pub enum Overlay {
|
||||
SelectionMarquee { rect: Rect, enclosed: bool, pointer: Point },
|
||||
/// A path preview (e.g. pen rubber band) in a colour (RGB).
|
||||
Path { path: BezPath, color: [u8; 3], width: f32, dashed: bool },
|
||||
/// A solid path preview that later calls return again, as the same shared `path`, for as long
|
||||
/// as it does not change (the settled outline of a long Blob Brush or Eraser drag). The host
|
||||
/// can keep what it built to draw it while `path` is the same allocation.
|
||||
SharedPath { path: std::sync::Arc<BezPath>, color: [u8; 3], width: f32 },
|
||||
/// A line segment (smart guide, handle line).
|
||||
Line { a: Point, b: Point, color: [u8; 3], dashed: bool },
|
||||
/// An anchor square: filled = selected.
|
||||
|
||||
@@ -59,6 +59,8 @@ pub struct Brackets {
|
||||
arc: ArcPath,
|
||||
/// [`TextKind::path_span`].
|
||||
span: (f64, f64),
|
||||
/// [`TextKind::path_text_start`]: below 0 when the type starts before an open path's start.
|
||||
text_start: f64,
|
||||
/// The end bracket was placed (not the end of the path, or once round it).
|
||||
end_set: bool,
|
||||
}
|
||||
@@ -78,7 +80,9 @@ impl Brackets {
|
||||
if !det.is_finite() || det.abs() < 1e-12 || arc.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self { id, xf: t.xf, arc, span: t.kind.path_span()?, end_set: end.is_some() })
|
||||
let span = t.kind.path_span()?;
|
||||
let text_start = t.kind.path_text_start().unwrap_or(span.0);
|
||||
Some(Self { id, xf: t.xf, arc, span, text_start, end_set: end.is_some() })
|
||||
}
|
||||
|
||||
/// Where bracket `b` stands, as a fraction of the path's length (past 1 round a closed path).
|
||||
@@ -269,7 +273,13 @@ impl BracketDrag {
|
||||
d -= d.round();
|
||||
}
|
||||
let place = |x: f64| if closed { x.rem_euclid(1.0) } else { x.clamp(0.0, 1.0) };
|
||||
let start = place(s + d);
|
||||
// Type that starts before an open path's start slides from there, and goes no
|
||||
// further before the path than it was: both brackets stay where they are.
|
||||
let lead = !closed && b.text_start < 0.0;
|
||||
if lead {
|
||||
d = d.max(0.0);
|
||||
}
|
||||
let start = if lead { (b.text_start + d).min(1.0) } else { place(s + d) };
|
||||
m.insert("start".into(), json!(start));
|
||||
if b.end_set {
|
||||
m.insert("end".into(), json!(if closed { place(e + d) } else { place(e + d).max(start) }));
|
||||
@@ -409,6 +419,35 @@ mod tests {
|
||||
assert!(v.get("flip").is_none(), "{v}");
|
||||
}
|
||||
|
||||
/// Type that starts before its open path slides from where it starts, not from the path's start.
|
||||
#[test]
|
||||
fn dragging_the_center_bracket_slides_type_that_starts_before_the_path() {
|
||||
let (mut d, id) = doc_with_path_type();
|
||||
if let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
|
||||
&& let TextKind::OnPath { start, .. } = &mut t.kind
|
||||
{
|
||||
*start = -0.25;
|
||||
}
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
// The center bracket stands in the middle of the brackets (the start bracket at the path's start).
|
||||
let v = preview(&drag(&cx, Point::new(250.0, 295.0), Point::new(280.0, 295.0), Mods::default())).clone();
|
||||
assert!((v["start"].as_f64().unwrap() + 0.15).abs() < 1e-6, "{v}");
|
||||
// Back past where it started: it stays there, and so does a placed end bracket.
|
||||
let v = preview(&drag(&cx, Point::new(250.0, 295.0), Point::new(150.0, 295.0), Mods::default())).clone();
|
||||
assert!((v["start"].as_f64().unwrap() + 0.25).abs() < 1e-6, "{v}");
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
|
||||
&& let TextKind::OnPath { end, .. } = &mut t.kind
|
||||
{
|
||||
*end = Some(0.5);
|
||||
}
|
||||
let ended = crate::testutil::cx(&d, &s, &p);
|
||||
// The center bracket stands at 0.25 of the path, between the start bracket (0) and the end.
|
||||
let v = preview(&drag(&ended, Point::new(175.0, 295.0), Point::new(130.0, 295.0), Mods::default())).clone();
|
||||
assert!((v["start"].as_f64().unwrap() + 0.25).abs() < 1e-6 && (v["end"].as_f64().unwrap() - 0.5).abs() < 1e-6, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_start_and_end_brackets_moves_them() {
|
||||
let (d, id) = doc_with_path_type();
|
||||
|
||||
@@ -150,7 +150,7 @@ fn is_inline(lay: &TextLayout, gi: usize) -> bool {
|
||||
/// inline graphics, glyphs whose font is gone and type whose transform is collapsed.
|
||||
fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option<CharBox> {
|
||||
let g: &PositionedGlyph = lay.glyphs.get(gi)?;
|
||||
if is_inline(lay, gi) {
|
||||
if is_inline(lay, gi) || g.off_path {
|
||||
return None;
|
||||
}
|
||||
let m = g.xf;
|
||||
@@ -161,7 +161,7 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option<CharBox> {
|
||||
let corners: Vec<Point> = lay
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|h| h.byte == g.byte && h.len > 0 && h.line == g.line)
|
||||
.filter(|h| h.byte == g.byte && h.len > 0 && h.line == g.line && !h.off_path)
|
||||
.filter_map(glyph_quad)
|
||||
.flatten()
|
||||
.map(|p| to_glyph * p)
|
||||
@@ -218,7 +218,8 @@ impl TouchTypeTool {
|
||||
let selected = std::iter::successors(Some(id), |c| cx.doc.parent_of(*c)).any(|c| cx.selection.contains(c));
|
||||
let t = text_of(cx, id).filter(|_| selected)?;
|
||||
let lay = self.layout(t);
|
||||
let gi = lay.glyphs.iter().position(|g| g.byte <= start && start < g.byte + g.len)?;
|
||||
// The character's first drawn glyph: glyphs before an open path's start are not drawn.
|
||||
let gi = lay.glyphs.iter().position(|g| g.byte <= start && start < g.byte + g.len && !g.off_path)?;
|
||||
Some((id, t, char_box(t, &lay, gi)?))
|
||||
}
|
||||
|
||||
@@ -230,7 +231,7 @@ impl TouchTypeTool {
|
||||
let lay = self.layout(t);
|
||||
let local = t.xf.inverse() * p;
|
||||
let boxes: Vec<(usize, [Point; 4])> = (lay.glyphs.iter().enumerate())
|
||||
.filter(|(gi, g)| g.len > 0 && !is_inline(&lay, *gi))
|
||||
.filter(|(gi, g)| g.len > 0 && !g.off_path && !is_inline(&lay, *gi))
|
||||
.filter_map(|(_, g)| Some((g.byte, glyph_quad(g)?)))
|
||||
.collect();
|
||||
let center = |q: &[Point; 4]| q[0].midpoint(q[2]);
|
||||
@@ -360,7 +361,9 @@ impl Tool for TouchTypeTool {
|
||||
let Some(t) = text_of(cx, id) else { return out };
|
||||
let lay = self.layout(t);
|
||||
let start = self.target.map_or(0, |(_, s)| s);
|
||||
let Some(b) = lay.glyphs.iter().position(|g| g.byte == start && g.len > 0).and_then(|gi| char_box(t, &lay, gi)) else {
|
||||
// The character's first drawn glyph: glyphs before an open path's start are not drawn.
|
||||
let first = lay.glyphs.iter().position(|g| g.byte == start && g.len > 0 && !g.off_path);
|
||||
let Some(b) = first.and_then(|gi| char_box(t, &lay, gi)) else {
|
||||
return out;
|
||||
};
|
||||
(Part::Move, b)
|
||||
|
||||
@@ -388,6 +388,60 @@ fn the_box_of_a_character_drawn_with_two_glyphs_holds_both() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A character of type on an open path that is not drawn, because it falls before the path's
|
||||
/// start, has no box, so the tool shows no handles for it; a drawn character of the same type has
|
||||
/// one.
|
||||
#[test]
|
||||
fn a_character_before_an_open_paths_start_has_no_box() {
|
||||
let mut d = Document::new(600.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut text = TextObject::point(Point::ZERO, "Type", CharStyle { size: 40.0, ..Default::default() });
|
||||
let line =
|
||||
vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(500.0, 200.0)], false)]);
|
||||
text.kind = vectorcraft_doc::TextKind::OnPath { path: line, start: -0.05, end: None };
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), &text);
|
||||
assert!(lay.glyphs[0].off_path && !lay.glyphs[1].off_path, "the T is before the path's start, the y is on it");
|
||||
assert!(char_box(&text, &lay, 0).is_none());
|
||||
assert!(TouchTypeTool { target: Some((id, 0)), ..Default::default() }.current(&cx).is_none());
|
||||
assert!(TouchTypeTool { target: Some((id, 1)), ..Default::default() }.current(&cx).is_some());
|
||||
// An All Caps ß is two glyphs: with the first before the path's start and the second drawn, the
|
||||
// character has the second's box.
|
||||
let mut caps = text.clone();
|
||||
caps.runs = vec![vectorcraft_doc::TextRun::new("ße", CharStyle { size: 40.0, all_caps: true, ..Default::default() })];
|
||||
if let vectorcraft_doc::TextKind::OnPath { start, .. } = &mut caps.kind {
|
||||
*start = 0.0;
|
||||
}
|
||||
let adv = vectorcraft_text::layout(FontDb::global(), &caps).glyphs[0].advance;
|
||||
if let vectorcraft_doc::TextKind::OnPath { start, .. } = &mut caps.kind {
|
||||
*start = -1.2 * adv / 400.0;
|
||||
}
|
||||
let mut d = Document::new(600.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(caps.clone())))).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let cx = crate::testutil::cx(&d, &s, &p);
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), &caps);
|
||||
assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path);
|
||||
let t = TouchTypeTool { target: Some((id, 0)), ..Default::default() };
|
||||
let (_, _, b) = t.current(&cx).expect("the ß has a box");
|
||||
assert!((b.pen - lay.glyphs[1].xf * Point::ZERO).hypot() < 1e-9, "{:?}", b.pen);
|
||||
// A press on its drawn glyph, on the path, starts a drag of it.
|
||||
let q = glyph_quad(&lay.glyphs[1]).unwrap();
|
||||
let at = Point::new(q[0].midpoint(q[2]).x, 200.0);
|
||||
assert!(in_quad(&q, at), "{at:?} {q:?}");
|
||||
let mut t = TouchTypeTool::default();
|
||||
let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -5.0));
|
||||
assert_eq!(preview(&acts[1])["start"], 0, "{:?}", acts[1]);
|
||||
}
|
||||
|
||||
/// A glyph skewed by its transform (the Skew effect of type on a path) keeps its own box.
|
||||
#[test]
|
||||
fn a_skewed_glyph_keeps_its_own_box() {
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
//! while hovering, so transforms pivot exactly on a corner. A drag starts from the point it grabbed
|
||||
//! snapped the same way, and the pointer snaps as a drawn point does (Smart Guides: the other
|
||||
//! objects' anchors, centres and edges; or the grid, pixels, Snap to Point), so a corner dragged
|
||||
//! onto another object's anchor scales exactly onto it.
|
||||
//! onto another object's anchor scales exactly onto it. Scale stretches along the bounding box's
|
||||
//! own width and height, turned with rotated objects until Reset Bounding Box; Reflect's default
|
||||
//! axis is the box's vertical one, and Shift constrains a drawn axis to 45° steps from the box.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_doc::{NodeId, OrientedBox};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::target_overlays;
|
||||
@@ -68,9 +70,28 @@ struct Drag {
|
||||
/// Alt was down at the press: a click opens the dialog, a drag transforms a copy.
|
||||
alt: bool,
|
||||
bounds: Rect,
|
||||
/// The angle of the selection's bounding box: Scale and Reflect act along its axes
|
||||
/// ([`TransformTool::matrix_in`]).
|
||||
angle: f64,
|
||||
/// Anchors are selected with Direct Selection: they transform alone along the page's axes,
|
||||
/// and a copy or a pattern-only drag transforms the whole objects along the box
|
||||
/// ([`Drag::angle_for`]).
|
||||
anchors: bool,
|
||||
/// The snapped pointer and Shift at the last drag event, to work out the transform again
|
||||
/// when the Alt state at the release changes what it transforms.
|
||||
pointer: Point,
|
||||
shift: bool,
|
||||
last: Affine,
|
||||
}
|
||||
|
||||
impl Drag {
|
||||
/// The angle Scale and Reflect act along, for a drag that copies (`copy`) or not, and that
|
||||
/// transforms only pattern tiles (`patterns_only`, the backquote key) or not.
|
||||
fn angle_for(&self, copy: bool, patterns_only: bool) -> f64 {
|
||||
if self.anchors && !copy && !patterns_only { 0.0 } else { self.angle }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TransformTool {
|
||||
kind: TransformKind,
|
||||
/// Custom reference point and the selection it was set for.
|
||||
@@ -125,6 +146,16 @@ impl TransformTool {
|
||||
|
||||
/// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout.
|
||||
pub fn matrix_for(&self, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) {
|
||||
self.matrix_in(0.0, o, start, p, shift, bounds)
|
||||
}
|
||||
|
||||
/// [`TransformTool::matrix_for`] with the bounding box turned by `angle` (counter-clockwise
|
||||
/// degrees): Scale stretches along the box's own width and height, and Reflect's default axis
|
||||
/// 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 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);
|
||||
@@ -132,15 +163,17 @@ impl TransformTool {
|
||||
}
|
||||
TransformKind::Reflect => {
|
||||
let mut v = p - o;
|
||||
if shift {
|
||||
v = vectorcraft_geom::constrain_angle(v, 45.0);
|
||||
if v.hypot() < 1e-9 {
|
||||
v = (frame.to_doc() * Point::new(0.0, 1.0)).to_vec2();
|
||||
} else if shift {
|
||||
v = (frame.to_doc() * vectorcraft_geom::constrain_angle(local(v).to_vec2(), 45.0).to_point()).to_vec2();
|
||||
}
|
||||
let theta = if v.hypot() < 1e-9 { std::f64::consts::FRAC_PI_2 } else { v.y.atan2(v.x) };
|
||||
let theta = v.y.atan2(v.x);
|
||||
(reflect_matrix(o, theta), format!("{:.1}°", vectorcraft_geom::normalize_deg(-theta.to_degrees())))
|
||||
}
|
||||
TransformKind::Scale => {
|
||||
let d0 = start - o;
|
||||
let d1 = p - o;
|
||||
let d0 = local(start - o).to_vec2();
|
||||
let d1 = local(p - o).to_vec2();
|
||||
let ratio = |a: f64, b: f64| if a.abs() > 1e-6 { b / a } else { 1.0 };
|
||||
let (mut sx, mut sy) = (ratio(d0.x, d1.x), ratio(d0.y, d1.y));
|
||||
if shift {
|
||||
@@ -151,7 +184,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, 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))), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0))
|
||||
}
|
||||
TransformKind::Shear => {
|
||||
// The shear axis follows the dominant drag direction. Shift keeps the drag on that
|
||||
@@ -227,7 +260,21 @@ impl Tool for TransformTool {
|
||||
// Alt: decided on release. A click sets the point and opens the dialog; a drag
|
||||
// transforms a copy about the reference point as it was.
|
||||
let origin = self.reference_point(cx).unwrap_or(bounds.center());
|
||||
self.drag = Some(Drag { start: p, pick, origin, began: false, alt: ev.mods.alt, bounds, last: Affine::IDENTITY });
|
||||
let anchors = vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()).is_some();
|
||||
let angle = selection_box(cx).map_or(0.0, |b| b.angle);
|
||||
self.drag = Some(Drag {
|
||||
start: p,
|
||||
pick,
|
||||
origin,
|
||||
began: false,
|
||||
alt: ev.mods.alt,
|
||||
bounds,
|
||||
angle,
|
||||
anchors,
|
||||
pointer: p,
|
||||
shift: false,
|
||||
last: Affine::IDENTITY,
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
@@ -247,8 +294,8 @@ 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_for(d.origin, d.pick, p, ev.mods.shift, d.bounds);
|
||||
d.last = m;
|
||||
let (m, text) = self.matrix_in(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));
|
||||
out.push(preview(m, ev.mods.alt, ev.backquote));
|
||||
@@ -257,8 +304,15 @@ impl Tool for TransformTool {
|
||||
PointerKind::Up => {
|
||||
let Some(d) = self.end_drag() else { return vec![] };
|
||||
if d.began {
|
||||
// The Alt state at release decides whether the result is a copy.
|
||||
vec![preview(d.last, ev.mods.alt, ev.backquote), Action::Commit]
|
||||
// The Alt and backquote states at release decide whether the result is a copy
|
||||
// or moves only pattern tiles, and with anchors selected, whether it acts
|
||||
// along the box or the page.
|
||||
let m = if d.anchors {
|
||||
self.matrix_in(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, d.pointer, d.shift, d.bounds).0
|
||||
} else {
|
||||
d.last
|
||||
};
|
||||
vec![preview(m, ev.mods.alt, ev.backquote), Action::Commit]
|
||||
} else {
|
||||
// A click sets the reference point; with Alt, it opens the dialog too.
|
||||
self.origin = Some((d.pick, cx.selection.objects.clone()));
|
||||
@@ -579,6 +633,70 @@ mod tests {
|
||||
assert!((m * Point::new(100.0, 120.0)).distance(Point::new(200.0, 120.0)) < 1e-9);
|
||||
}
|
||||
|
||||
/// With the selection turned by 30°, a Scale drag stretches along the box's own sides, and
|
||||
/// Reflect's default axis and Shift steps start from the box's axes while a drawn axis stays
|
||||
/// the line drawn on the page.
|
||||
#[test]
|
||||
fn scale_and_reflect_drags_follow_a_turned_bounding_box() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let c = Point::new(150.0, 150.0);
|
||||
d.node_mut(id).unwrap().transform(about(c, Affine::rotate(-30f64.to_radians())), false);
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
(cx.smart_guides, cx.snap_to_point) = (false, false);
|
||||
let frame = OrientedBox { angle: 30.0, rect: Rect::ZERO };
|
||||
let along = |x: f64, y: f64| c + (frame.to_doc() * Point::new(x, y)).to_vec2();
|
||||
// The right side's middle dragged out to twice its distance: the width doubles along the
|
||||
// box, and the height stays.
|
||||
let mut t = TransformTool::new(TransformKind::Scale);
|
||||
let (from, to) = (along(50.0, 0.0), along(100.0, 0.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y));
|
||||
let m = matrix(&a[1]);
|
||||
assert!((m * along(50.0, 50.0)).distance(along(100.0, 50.0)) < 1e-9, "{m:?}");
|
||||
assert!((m * along(0.0, 50.0)).distance(along(0.0, 50.0)) < 1e-9, "{m:?}");
|
||||
assert!(a.iter().any(|x| matches!(x, Action::Preview(..))));
|
||||
|
||||
let t = TransformTool::new(TransformKind::Reflect);
|
||||
// No axis drawn: the box's vertical axis.
|
||||
let (m, _) = t.matrix_in(30.0, c, c, c, false, Rect::ZERO);
|
||||
assert!((m * along(40.0, 10.0)).distance(along(-40.0, 10.0)) < 1e-9, "{m:?}");
|
||||
// Shift: 39° from the box's horizontal axis steps to 45° from it.
|
||||
let (m, text) = t.matrix_in(30.0, c, c, along(10.0, 8.0), true, Rect::ZERO);
|
||||
assert!((m * along(10.0, 0.0)).distance(along(0.0, 10.0)) < 1e-9, "{m:?}");
|
||||
assert_eq!(text, "-15.0°");
|
||||
// A drawn axis is the line on the page.
|
||||
let (m, _) = t.matrix_in(30.0, c, c, c + Vec2::new(0.0, -50.0), false, Rect::ZERO);
|
||||
assert!((m * Point::new(100.0, 120.0)).distance(Point::new(200.0, 120.0)) < 1e-9, "{m:?}");
|
||||
|
||||
// Anchors selected with Direct Selection scale alone along the page's axes; Alt at the
|
||||
// release copies the whole object, which scales along its box.
|
||||
let mut s = s.clone();
|
||||
s.anchors.insert(id, [(0, 0), (0, 1)].into());
|
||||
let mut cx = crate::testutil::cx(&d, &s, &p);
|
||||
(cx.smart_guides, cx.snap_to_point) = (false, false);
|
||||
let page_axes = |m: Affine| m.as_coeffs()[1].abs() < 1e-9 && m.as_coeffs()[2].abs() < 1e-9;
|
||||
let (from, to) = (along(50.0, 0.0), along(100.0, 30.0));
|
||||
for copy in [false, true] {
|
||||
let mut t = TransformTool::new(TransformKind::Scale);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y));
|
||||
assert!(page_axes(matrix(&a[1])), "{a:?}");
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, to.x, to.y).with_mods(if copy { alt() } else { Mods::default() }));
|
||||
assert_eq!(page_axes(matrix(&up[0])), !copy, "copy {copy}: {up:?}");
|
||||
}
|
||||
// With the backquote key the drag moves only the whole objects' pattern tiles, which
|
||||
// scale along the box, both while dragging and at the release.
|
||||
let mut t = TransformTool::new(TransformKind::Scale);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y));
|
||||
let a = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, to.x, to.y) });
|
||||
assert!(!page_axes(matrix(&a[1])), "{a:?}");
|
||||
let up = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Up, to.x, to.y) });
|
||||
assert!(!page_axes(matrix(&up[0])), "{up:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shear_horizontal_keeps_origin_row() {
|
||||
let t = TransformTool::new(TransformKind::Shear);
|
||||
|
||||
@@ -19,7 +19,7 @@ const FIELD_W: f32 = 96.0;
|
||||
|
||||
/// Object › Plug-ins › Path Precision Tools › Corner Tool.
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
app.select_tool(ID);
|
||||
app.try_select_tool(ID)?;
|
||||
crate::panel_docking::reveal_panel(app, super::ID)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const LABEL_W: f32 = 80.0;
|
||||
|
||||
/// Object › Plug-ins › Path Precision Tools › Shared Edge Merge Tool.
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
app.select_tool(ID);
|
||||
app.try_select_tool(ID)?;
|
||||
crate::panel_docking::reveal_panel(app, super::ID)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ impl VectorcraftApp {
|
||||
hidden_chars: Default::default(),
|
||||
spelling: Default::default(),
|
||||
selection_marks: Default::default(),
|
||||
shared_paths: Default::default(),
|
||||
selection_box: None,
|
||||
selection_bounds: None,
|
||||
content_bounds: None,
|
||||
@@ -184,6 +185,8 @@ impl VectorcraftApp {
|
||||
host_iteration: 0,
|
||||
#[cfg(test)]
|
||||
now_override: None,
|
||||
#[cfg(test)]
|
||||
fail_next_tool_choice: None,
|
||||
tick_iteration: None,
|
||||
input_frames: vec![],
|
||||
shortcuts_frames: vec![],
|
||||
|
||||
@@ -406,34 +406,54 @@ impl VectorcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a tool (also used by the toolbar and shortcuts).
|
||||
/// Select a tool (also used by the toolbar and shortcuts). A choice that fails shows its error
|
||||
/// in the status bar.
|
||||
pub fn select_tool(&mut self, id: &str) {
|
||||
if let Err(e) = self.try_select_tool(id) {
|
||||
self.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a tool for a command ([`Self::select_tool`]): a choice that fails returns its error,
|
||||
/// and the tool remembered for the toolbar group and for the Touch workspace's drawing mode
|
||||
/// stays as it was.
|
||||
pub(crate) fn try_select_tool(&mut self, id: &str) -> Result<(), String> {
|
||||
// The tip of a pen whose eraser end selected the Eraser doesn't undo this choice, and
|
||||
// neither does letting go of a held tool shortcut.
|
||||
self.pen_end.forget_tool();
|
||||
self.held_tool.clear();
|
||||
self.activate_tool(id, true);
|
||||
self.activate_tool(id, true)?;
|
||||
let now = self.session.tool_id();
|
||||
if crate::touch::is_drawing_tool(now) {
|
||||
self.ui.touch_drawing_tool = Some(now.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Go back to tool `id` after a tool that stood in for it (the pen's eraser end, [`stylus`]):
|
||||
/// a perspective grid hidden meanwhile stays hidden.
|
||||
/// a perspective grid hidden meanwhile stays hidden. A switch that fails shows its error in the
|
||||
/// status bar.
|
||||
pub(crate) fn return_to_tool(&mut self, id: &str) {
|
||||
self.activate_tool(id, false);
|
||||
if let Err(e) = self.activate_tool(id, false) {
|
||||
self.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
/// Make tool `id` active: chosen afresh (`fresh`, [`vectorcraft_engine::Session::select_tool`]),
|
||||
/// or back from a tool that stood in for it ([`vectorcraft_engine::Session::switch_tool`]).
|
||||
fn activate_tool(&mut self, id: &str, fresh: bool) {
|
||||
/// The toolbar group remembers `id` only when the switch succeeds.
|
||||
fn activate_tool(&mut self, id: &str, fresh: bool) -> Result<(), String> {
|
||||
let v = self.view_info();
|
||||
let before = self.session.tool_id();
|
||||
let switched = if fresh { self.session.select_tool(id, v) } else { self.session.switch_tool(id, v) };
|
||||
if let Err(e) = switched {
|
||||
self.ui.status = e.to_string();
|
||||
}
|
||||
#[cfg(test)]
|
||||
let failed = self.fail_next_tool_choice.take();
|
||||
#[cfg(not(test))]
|
||||
let failed: Option<String> = None;
|
||||
let switched = match failed {
|
||||
Some(e) => Err(e),
|
||||
None if fresh => self.session.select_tool(id, v).map_err(|e| e.to_string()),
|
||||
None => self.session.switch_tool(id, v).map_err(|e| e.to_string()),
|
||||
};
|
||||
if before != self.session.tool_id() {
|
||||
// A control gesture cannot resume after another tool has owned the pointer.
|
||||
self.control_rotate_press = None;
|
||||
@@ -445,13 +465,15 @@ impl VectorcraftApp {
|
||||
{
|
||||
panels::artboards::select(self, panels::artboards::selected(self, n));
|
||||
}
|
||||
self.ui.flyout = None;
|
||||
switched?;
|
||||
if let Some(g) = vectorcraft_tools::catalog::group_of(id)
|
||||
&& let Some(slot) = self.ui.group_tool.get_mut(g)
|
||||
{
|
||||
*slot = id.to_string();
|
||||
}
|
||||
toolbar::remember(self, id);
|
||||
self.ui.flyout = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show a transient status message.
|
||||
|
||||
@@ -51,6 +51,9 @@ pub struct CanvasCache {
|
||||
/// The selected objects' outlines, anchors and handles as drawn in the last frame. A frame that
|
||||
/// does not draw them drops them ([`canvas::show`]).
|
||||
pub selection_marks: canvas::SelectionMarks,
|
||||
/// The meshes the tools' shared paths (a long Blob Brush or Eraser drag's settled outline) were
|
||||
/// drawn with in the last frame. A frame that does not draw them drops them ([`canvas::show`]).
|
||||
pub shared_paths: canvas::SharedPaths,
|
||||
/// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds and
|
||||
/// Measure Type by Glyph Bounds).
|
||||
pub selection_box: Option<((u64, u64, vectorcraft_effects::preview::Measure), Option<vectorcraft_doc::OrientedBox>)>,
|
||||
|
||||
@@ -60,6 +60,7 @@ use input::handle_input;
|
||||
use outline::KEY_OUTLINE;
|
||||
#[cfg(test)]
|
||||
use outline::{OVERLAY_MAX_ANCHORS, c32, node_outline, walk_drawn};
|
||||
pub use overlays::SharedPaths;
|
||||
#[cfg(test)]
|
||||
use overlays::selected_anchor;
|
||||
use overlays::{HandleLook, blend_widgets_overlay, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay};
|
||||
@@ -244,6 +245,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let hidden_chars = std::mem::take(&mut app.canvas.hidden_chars);
|
||||
// The selection's kept marks. Likewise, a frame that does not draw them drops them.
|
||||
let selection_marks = std::mem::take(&mut app.canvas.selection_marks);
|
||||
// The meshes of the tools' shared paths. Likewise, a frame that does not draw the tools'
|
||||
// overlays drops them.
|
||||
let mut shared_paths = std::mem::take(&mut app.canvas.shared_paths);
|
||||
// A release ends the Image Trace panel's eye-button hold, also when the panel is no longer
|
||||
// drawn, so a later press elsewhere does not show the source image.
|
||||
if app.ui.trace_hold_source.is_some() && !ui.input(|i| i.pointer.any_down()) {
|
||||
@@ -415,7 +419,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
original_path_overlay(app, &painter, &xf);
|
||||
|
||||
let overlays = selection_visuals(app, &painter, &xf, (hidden_chars, selection_marks, hover_still), revolve_edit, view_info);
|
||||
draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs));
|
||||
draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs), &mut shared_paths);
|
||||
app.canvas.shared_paths = shared_paths;
|
||||
if !revolve_edit {
|
||||
shaper_widget_overlay(app, &painter, &xf);
|
||||
crate::panels::three_d::paint_graphic_handles(app, &painter, &xf);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::Mesh;
|
||||
use egui::epaint::{TessellationOptions, Tessellator};
|
||||
|
||||
use super::artboards::names_an_artboard;
|
||||
use super::input::marquee_through;
|
||||
use super::outline::{KEY_OUTLINE, c32, node_outline, selection_hit_at, stroke_path, to_screen_path, too_many_anchors};
|
||||
use super::selection_marks::{MarkOptions, SelectionMarks};
|
||||
use super::{Color32, CornerRadius, NodeKind, Overlay, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2};
|
||||
use super::{BezPath, Color32, CornerRadius, NodeKind, Overlay, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2};
|
||||
|
||||
pub(super) fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(h) = app.hover_doc else { return };
|
||||
@@ -351,7 +357,75 @@ pub(super) fn ime_geometry(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &X
|
||||
app.ime_caret_rect = Some((r, r));
|
||||
}
|
||||
|
||||
pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook) {
|
||||
/// The meshes the tools' shared paths ([`Overlay::SharedPath`]) were drawn with in the last frame
|
||||
/// ([`crate::CanvasCache::shared_paths`]). Each is kept with its path, by the path's allocation, and
|
||||
/// with the view, clip and stroke it was built for: a path that comes back unchanged at the same view
|
||||
/// is drawn with the same mesh, without flattening or tessellating it again. A frame keeps only the
|
||||
/// meshes it draws; a frame that draws no overlays drops them all ([`super::show`]).
|
||||
#[derive(Default)]
|
||||
pub struct SharedPaths {
|
||||
pub(super) meshes: HashMap<usize, SharedMesh>,
|
||||
}
|
||||
|
||||
pub(super) struct SharedMesh {
|
||||
/// Held so that the allocation, the key of the map, is not reused while the mesh is kept.
|
||||
path: Arc<BezPath>,
|
||||
key: SharedKey,
|
||||
pub(super) mesh: Arc<Mesh>,
|
||||
}
|
||||
|
||||
/// What a shared path's mesh depends on besides the path.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct SharedKey {
|
||||
/// The view's mapping onto the screen ([`Xf::affine`]).
|
||||
view: [f64; 6],
|
||||
/// The painter's clip: the lines of the path outside it have no triangles in the mesh.
|
||||
clip: egui::Rect,
|
||||
stroke: Stroke,
|
||||
pixels_per_point: f32,
|
||||
tessellation: TessellationOptions,
|
||||
}
|
||||
|
||||
impl SharedPaths {
|
||||
/// The mesh that draws `path` with `s` at the view `xf` with the painter `p`: the one kept in
|
||||
/// this frame or in `last` (the last frame's) when it was built for the same, else a new one.
|
||||
/// It is kept for the next frame.
|
||||
fn mesh(&mut self, last: &mut HashMap<usize, SharedMesh>, path: &Arc<BezPath>, xf: &Xf, s: Stroke, p: &egui::Painter) -> Arc<Mesh> {
|
||||
let ctx = p.ctx();
|
||||
let key = SharedKey {
|
||||
view: xf.affine().as_coeffs(),
|
||||
clip: p.clip_rect(),
|
||||
stroke: s,
|
||||
pixels_per_point: ctx.pixels_per_point(),
|
||||
tessellation: ctx.tessellation_options(|o| *o),
|
||||
};
|
||||
let at = Arc::as_ptr(path) as usize;
|
||||
let fits = |m: &SharedMesh| m.key == key && Arc::ptr_eq(&m.path, path);
|
||||
if let Some(kept) = self.meshes.get(&at).filter(|m| fits(m)) {
|
||||
return Arc::clone(&kept.mesh);
|
||||
}
|
||||
let kept = match last.remove(&at).filter(|m| fits(m)) {
|
||||
Some(kept) => kept,
|
||||
None => {
|
||||
let font_image_size = ctx.fonts(|f| f.font_image_size());
|
||||
let mut tessellator = Tessellator::new(key.pixels_per_point, key.tessellation, font_image_size, vec![]);
|
||||
tessellator.set_clip_rect(key.clip);
|
||||
let mut mesh = Mesh::default();
|
||||
for line in to_screen_path(path, xf) {
|
||||
tessellator.tessellate_shape(Shape::line(line, s), &mut mesh);
|
||||
}
|
||||
SharedMesh { path: Arc::clone(path), key, mesh: Arc::new(mesh) }
|
||||
}
|
||||
};
|
||||
let mesh = Arc::clone(&kept.mesh);
|
||||
self.meshes.insert(at, kept);
|
||||
mesh
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook, shared: &mut SharedPaths) {
|
||||
// The meshes kept from the last frame; those this frame does not draw are dropped.
|
||||
let mut last = std::mem::take(&mut shared.meshes);
|
||||
for o in overlays {
|
||||
match o {
|
||||
// Where the tools select: the document rectangle, turned with the view.
|
||||
@@ -389,6 +463,12 @@ pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t:
|
||||
stroke_path(p, path, xf, s);
|
||||
}
|
||||
}
|
||||
Overlay::SharedPath { path, color, width } => {
|
||||
let mesh = shared.mesh(&mut last, path, xf, Stroke::new(*width, c32(*color)), p);
|
||||
if !mesh.is_empty() {
|
||||
p.add(Shape::mesh(mesh));
|
||||
}
|
||||
}
|
||||
Overlay::Line { a, b, color, dashed } => {
|
||||
let s = Stroke::new(1.0, c32(*color));
|
||||
let (a, b) = (xf.to_screen(*a), xf.to_screen(*b));
|
||||
|
||||
@@ -17,6 +17,7 @@ fn marquee_feedback_explains_the_rule_and_stays_on_canvas() {
|
||||
&[Overlay::SelectionMarquee { rect: Rect::new(20.0, y - 60.0, 300.0, y), enclosed, pointer: Point::new(315.0, y) }],
|
||||
&t,
|
||||
overlays::HandleLook::of(&prefs),
|
||||
&mut overlays::SharedPaths::default(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -61,6 +62,89 @@ fn marquee_feedback_explains_the_rule_and_stays_on_canvas() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool's shared path ([`Overlay::SharedPath`], the settled outline of a long Blob Brush or Eraser
|
||||
/// drag) is drawn with the mesh kept from the last frame while the path and the view stay the same,
|
||||
/// and that mesh is the one the plain path draws. Another path, even one with the same elements, or
|
||||
/// a new view builds a new mesh, and a frame keeps only the meshes it draws.
|
||||
#[test]
|
||||
fn a_shared_tool_path_is_drawn_with_the_mesh_kept_while_it_and_the_view_stay() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, crate::theme::Brightness::MediumDark);
|
||||
let prefs = vectorcraft_engine::Prefs::default();
|
||||
let mut shared = overlays::SharedPaths::default();
|
||||
let frame = |overlays: &[Overlay], zoom: f64, shared: &mut overlays::SharedPaths| {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let xf = Xf { rect: ui.max_rect(), zoom, center: Point::new(200.0, 150.0), rot: 0.3 };
|
||||
overlays::draw_overlays(ui.painter(), &xf, overlays, &t, overlays::HandleLook::of(&prefs), shared);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
};
|
||||
let meshes = |shapes: &[egui::epaint::ClippedShape]| -> Vec<std::sync::Arc<egui::Mesh>> {
|
||||
shapes.iter().filter_map(|c| if let Shape::Mesh(m) = &c.shape { Some(std::sync::Arc::clone(m)) } else { None }).collect()
|
||||
};
|
||||
let one = |shapes: Vec<egui::epaint::ClippedShape>| {
|
||||
let found = meshes(&shapes);
|
||||
assert_eq!(found.len(), 1, "one mesh");
|
||||
std::sync::Arc::clone(&found[0])
|
||||
};
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((60.0, 80.0));
|
||||
path.curve_to((120.0, 20.0), (220.0, 260.0), (330.0, 120.0));
|
||||
path.close_path();
|
||||
path.move_to((150.0, 150.0));
|
||||
path.line_to((250.0, 160.0));
|
||||
let a = std::sync::Arc::new(path.clone());
|
||||
let b = std::sync::Arc::new(path.clone());
|
||||
let shared_of = |p: &std::sync::Arc<BezPath>| Overlay::SharedPath { path: std::sync::Arc::clone(p), color: [0x80, 0x80, 0x80], width: 1.0 };
|
||||
let first = one(frame(&[shared_of(&a)], 1.0, &mut shared));
|
||||
assert!(!first.vertices.is_empty());
|
||||
let again = one(frame(&[shared_of(&a)], 1.0, &mut shared));
|
||||
assert!(std::sync::Arc::ptr_eq(&first, &again), "the same path at the same view is drawn with the kept mesh");
|
||||
// The plain path draws the same triangles.
|
||||
let plain = frame(&[Overlay::Path { path: path.clone(), color: [0x80, 0x80, 0x80], width: 1.0, dashed: false }], 1.0, &mut shared);
|
||||
assert!(shared.meshes.is_empty(), "a frame that draws no shared path keeps no mesh");
|
||||
let lines: Vec<_> = plain.into_iter().filter(|c| matches!(c.shape, Shape::Path(_))).collect();
|
||||
assert_eq!(lines.len(), 2, "a line for each subpath");
|
||||
let tessellated = ctx.tessellate(lines, ctx.pixels_per_point());
|
||||
let triangles: Vec<_> = tessellated
|
||||
.into_iter()
|
||||
.filter_map(|c| if let egui::epaint::Primitive::Mesh(m) = c.primitive { Some(m) } else { None })
|
||||
.flat_map(|m| m.vertices)
|
||||
.map(|v| (v.pos, v.color))
|
||||
.collect();
|
||||
assert_eq!(triangles, first.vertices.iter().map(|v| (v.pos, v.color)).collect::<Vec<_>>());
|
||||
// A dropped mesh, another allocation of the same path and a new view each build a new mesh.
|
||||
let rebuilt = one(frame(&[shared_of(&a)], 1.0, &mut shared));
|
||||
assert!(!std::sync::Arc::ptr_eq(&first, &rebuilt) && *first == *rebuilt);
|
||||
let other = one(frame(&[shared_of(&b)], 1.0, &mut shared));
|
||||
assert!(!std::sync::Arc::ptr_eq(&rebuilt, &other) && *rebuilt == *other);
|
||||
let zoomed = one(frame(&[shared_of(&b)], 2.0, &mut shared));
|
||||
assert!(!std::sync::Arc::ptr_eq(&other, &zoomed) && *other != *zoomed);
|
||||
// Two paths in one frame: each keeps its own mesh, and only those two are kept.
|
||||
let both = meshes(&frame(&[shared_of(&a), shared_of(&b)], 2.0, &mut shared));
|
||||
let both_again = meshes(&frame(&[shared_of(&a), shared_of(&b)], 2.0, &mut shared));
|
||||
assert_eq!(both.len(), 2);
|
||||
assert!(both.iter().zip(&both_again).all(|(x, y)| std::sync::Arc::ptr_eq(x, y)));
|
||||
assert!(std::sync::Arc::ptr_eq(&both[1], &zoomed), "b at zoom 2 was kept from its frame");
|
||||
assert_eq!(shared.meshes.len(), 2);
|
||||
// A path off the canvas gets no triangles and draws nothing, and the lines of one partly off
|
||||
// it are left out of its mesh.
|
||||
let mut far = path.clone();
|
||||
far.apply_affine(vectorcraft_geom::Affine::translate((5000.0, 5000.0)));
|
||||
let far = std::sync::Arc::new(far);
|
||||
assert!(meshes(&frame(&[shared_of(&far)], 1.0, &mut shared)).is_empty());
|
||||
assert!(shared.meshes.values().all(|m| m.mesh.is_empty()) && shared.meshes.len() == 1);
|
||||
let mut half = path.clone();
|
||||
half.move_to((5000.0, 5000.0));
|
||||
half.line_to((5100.0, 5000.0));
|
||||
let half = one(frame(&[shared_of(&std::sync::Arc::new(half))], 1.0, &mut shared));
|
||||
assert_eq!(*half, *first, "the subpath off the canvas adds no triangles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() {
|
||||
let mut observed = Vec::new();
|
||||
@@ -1331,6 +1415,64 @@ fn switch_selection_and_drawing_modes_command() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Switch Selection and Drawing Modes whose tool choice fails returns the error, in both
|
||||
/// directions: the tool stays, the drawing tool to return to stays, and no undo step is added.
|
||||
#[test]
|
||||
fn a_failed_mode_switch_returns_its_error() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap();
|
||||
let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
|
||||
let steps = undo(&app);
|
||||
app.select_tool("rectangle");
|
||||
app.ui.touch_drawing_tool = Some("pen".into());
|
||||
app.fail_next_tool_choice = Some("no switch".into());
|
||||
let r = app.run(crate::touch::TOGGLE_MODE, json!({}));
|
||||
assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{r:?}");
|
||||
assert_eq!((app.session.tool_id(), app.ui.touch_drawing_tool.as_deref(), undo(&app)), ("rectangle", Some("pen"), steps), "drawing mode");
|
||||
app.select_tool("selection");
|
||||
app.fail_next_tool_choice = Some("no switch".into());
|
||||
let r = app.run(crate::touch::TOGGLE_MODE, json!({}));
|
||||
assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{r:?}");
|
||||
assert_eq!((app.session.tool_id(), app.ui.touch_drawing_tool.as_deref(), undo(&app)), ("selection", Some("pen"), steps), "selection mode");
|
||||
// The next switch works again.
|
||||
assert_eq!(app.run(crate::touch::TOGGLE_MODE, json!({})).unwrap(), json!({"tool": "pen"}));
|
||||
assert_eq!(undo(&app), steps);
|
||||
}
|
||||
|
||||
/// The commands that choose a tool (Select Tool, Crop Image, and the Path Precision Tools' Corner
|
||||
/// Tool and Shared Edge Merge Tool) return the error when the choice fails, and the tool and the
|
||||
/// toolbar group's tool stay as they were.
|
||||
#[test]
|
||||
fn tool_commands_return_a_failed_choice() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let img = image::RgbaImage::from_pixel(20, 10, image::Rgba([200, 120, 40, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&png);
|
||||
app.run("file.place", json!({"name": "texture.png", "dataBase64": data, "link": false, "at": [150, 150]})).unwrap();
|
||||
app.select_tool("selection");
|
||||
let group = vectorcraft_tools::catalog::group_of("ellipse").unwrap();
|
||||
let commands = [
|
||||
("tool.select", json!({"tool": "ellipse"})),
|
||||
("ui.cropImage", json!({})),
|
||||
(crate::addons::pathtools::corner::COMMAND, json!({})),
|
||||
(crate::addons::pathtools::shared_edge::COMMAND, json!({})),
|
||||
];
|
||||
for (command, params) in commands {
|
||||
let slot = app.ui.group_tool[group].clone();
|
||||
app.fail_next_tool_choice = Some("no switch".into());
|
||||
let r = app.run(command, params.clone());
|
||||
assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{command}: {r:?}");
|
||||
assert_eq!(app.session.tool_id(), "selection", "{command}");
|
||||
assert_eq!(app.ui.group_tool[group], slot, "{command}");
|
||||
app.run(command, params).unwrap();
|
||||
assert_ne!(app.session.tool_id(), "selection", "{command} works again");
|
||||
app.select_tool("selection");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Artboard tool's label holds the artboard's name (never translated); the tools' own
|
||||
/// labels are interface text.
|
||||
#[test]
|
||||
|
||||
@@ -48,6 +48,8 @@ const OT_SWITCHES: &[(&str, &str)] = &[
|
||||
("ot_titl", "titl"),
|
||||
("ot_ordn", "ordn"),
|
||||
("ot_frac", "frac"),
|
||||
("ot_hkna", "hkna"),
|
||||
("ot_ital", "ital"),
|
||||
];
|
||||
/// The feature tags the Figure menu sets.
|
||||
const FIGURE_TAGS: [&str; 4] = ["tnum", "onum", "pnum", "lnum"];
|
||||
@@ -56,8 +58,21 @@ const FIGURE_VALUES: [&str; 5] = ["default", "tabularLining", "proportionalLinin
|
||||
/// The values of the Position field, in the order of the OpenType panel's Position menu.
|
||||
const POSITION_VALUES: [&str; 5] = ["default", "superscript", "subscript", "numerator", "denominator"];
|
||||
/// The fields of OpenType Features.
|
||||
const OT_FIELDS: &[&str] =
|
||||
&["ot_figure", "ot_position", "ot_liga", "ot_calt", "ot_dlig", "ot_swsh", "ot_salt", "ot_titl", "ot_ordn", "ot_frac", "ot_sets"];
|
||||
const OT_FIELDS: &[&str] = &[
|
||||
"ot_figure",
|
||||
"ot_position",
|
||||
"ot_liga",
|
||||
"ot_calt",
|
||||
"ot_dlig",
|
||||
"ot_swsh",
|
||||
"ot_salt",
|
||||
"ot_titl",
|
||||
"ot_ordn",
|
||||
"ot_frac",
|
||||
"ot_hkna",
|
||||
"ot_ital",
|
||||
"ot_sets",
|
||||
];
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Character Style Options").into(),
|
||||
@@ -106,6 +121,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
"ot_titl",
|
||||
"ot_ordn",
|
||||
"ot_frac",
|
||||
"ot_hkna",
|
||||
"ot_ital",
|
||||
"ot_sets",
|
||||
"section",
|
||||
]
|
||||
@@ -926,6 +943,8 @@ pub(crate) fn opentype_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
("ot_titl", tl!("Titling Alternates")),
|
||||
("ot_ordn", tl!("Ordinals")),
|
||||
("ot_frac", tl!("Fractions")),
|
||||
("ot_hkna", tl!("H or V Style")),
|
||||
("ot_ital", tl!("Roman Italics")),
|
||||
] {
|
||||
switch(ui, d, key, caption, true);
|
||||
}
|
||||
@@ -2042,6 +2061,23 @@ mod tests {
|
||||
assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["frac"]));
|
||||
}
|
||||
|
||||
/// H or V Style and Roman Italics are on, off or unset switches of OpenType Features: they show
|
||||
/// the style's `hkna` and `ital` tags, a changed switch replaces its tag and keeps the others, an
|
||||
/// unset one removes it, and a style without them keeps none.
|
||||
#[test]
|
||||
fn kana_style_and_roman_italics_are_opentype_switches() {
|
||||
let mut d = dialog_for(json!({"features": ["hkna", "-ital", "dlig"]}));
|
||||
assert_eq!((&d.fields["ot_hkna"], &d.fields["ot_ital"]), (&json!(true), &json!(false)));
|
||||
assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["hkna", "-ital", "dlig"]), "untouched");
|
||||
d.fields.insert("ot_ital".into(), json!(true));
|
||||
d.fields.insert("ot_hkna".into(), Value::Null);
|
||||
assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["dlig", "ital"]));
|
||||
let mut d = dialog_for(json!({}));
|
||||
assert!(d.fields["ot_hkna"].is_null() && d.fields["ot_ital"].is_null());
|
||||
d.fields.insert("ot_hkna".into(), json!(true));
|
||||
assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["hkna"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_and_opentype_sections_show_their_controls() {
|
||||
let mut d = dialog_for(json!({}));
|
||||
@@ -2066,6 +2102,8 @@ mod tests {
|
||||
"Titling Alternates",
|
||||
"Ordinals",
|
||||
"Fractions",
|
||||
"H or V Style",
|
||||
"Roman Italics",
|
||||
"Stylistic Sets",
|
||||
"ss01",
|
||||
"ss20",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user