Merge main into fix/type-no-op-undo-more

This commit is contained in:
dotCooCoo
2026-11-14 09:49:48 -08:00
124 changed files with 9903 additions and 540 deletions
+7 -5
View File
@@ -518,16 +518,18 @@ fn sort(s: &mut Session, p: &Value) -> Result<Value> {
}
let wanted: Vec<(&Sorted, f64)> = rank.iter().filter_map(|i| items.get(*i)).zip(values).collect();
if apply == "opacity" {
if wanted.iter().all(|(o, v)| (o.opacity - v).abs() < 1e-6) {
// The opacity each object is given, as stored; an object whose stored opacity it already is has not changed.
let given: Vec<(NodeId, f32)> = wanted.iter().map(|(o, v)| (o.id, (v / 100.0) as f32)).collect();
let changed: Vec<NodeId> = given.iter().filter(|(id, a)| d.node(*id).is_some_and(|n| n.opacity != *a)).map(|(id, _)| *id).collect();
if changed.is_empty() {
return Ok(count);
}
s.edit("Sort Objects", |d, _| {
for (o, v) in &wanted {
if let Some(n) = d.node_mut(o.id) {
n.opacity = (v / 100.0) as f32;
for (id, a) in &given {
if let Some(n) = d.node_mut(*id) {
n.opacity = *a;
}
}
let changed: Vec<NodeId> = wanted.iter().filter(|(o, v)| (o.opacity - v).abs() >= 1e-6).map(|(o, _)| o.id).collect();
keep_series_opacity(d, &changed);
Ok(())
})?;
+1 -1
View File
@@ -11,7 +11,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pencil",
[],
None,
"{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
"{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end, and closes it when `closed` is true (left out, when the stroke ends within four Fidelities of the path's other end, 6 to 400 pt); with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
has_doc,
freehand
),
+22 -5
View File
@@ -217,11 +217,18 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
None => None,
};
// An extending stroke that ends within four Fidelities of the path's other end closes it:
// at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at.
// `closed`, when given, says whether the stroke closes the path: the Pencil and the
// Paintbrush send it, by their Close Paths When Ends Are Within and Alt at the release.
// Left out, an extending stroke that ends within four Fidelities of the path's other end
// closes it: at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at.
let close_tol = (tol * 4.0).clamp(6.0, 400.0);
let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol);
if closing && let Some(l) = pts.last_mut() {
let near_first = pts.last().is_some_and(|p| p.distance(first) <= close_tol);
let wants_close = if p.get("closed").is_some_and(|v| !v.is_null()) { closed } else { near_first };
let closing = connect.is_none() && sp.anchors.len() >= 2 && wants_close;
// A stroke ending near the other end ends on it; one ending farther away closes with a
// straight segment back to it.
let ends_on_first = closing && near_first;
if ends_on_first && let Some(l) = pts.last_mut() {
*l = first;
}
if let Some((_, _, y, _)) = &connect
@@ -238,13 +245,23 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
add.remove(0);
}
sp.anchors.extend(add);
if closing
if ends_on_first
&& sp.anchors.len() > 2
&& let Some(l) = sp.anchors.pop()
&& let Some(a0) = sp.anchors.first_mut()
{
a0.h_in = l.h_in;
sp.closed = true;
} else if closing && !ends_on_first && sp.anchors.len() > 2 {
// Closed from away from the other end: a straight segment back to it, whatever
// handles the open ends carried.
sp.closed = true;
if let Some(l) = sp.anchors.last_mut() {
l.h_out = l.p;
}
if let Some(f) = sp.anchors.first_mut() {
f.h_in = f.p;
}
}
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
+2 -14
View File
@@ -352,20 +352,8 @@ fn svg_darkness_preserves_source_model_and_gradient_appearance() {
#[test]
fn svg_darkness_uses_rendered_shadow_in_non_srgb_working_space() {
const CHILD: &str = "VECTORCRAFT_TEST_SHADOW_RGB_PROFILE";
if std::env::var_os(CHILD).is_none() {
// Color settings are process-wide: exercise this profile in a separate test process.
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
"isolated color profile test failed:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr),
);
// Color settings are process-wide: exercise this profile in a separate test process.
if !crate::tests::in_own_process("cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space") {
return;
}
use vectorcraft_color::{Color, Paint, cms};
+18
View File
@@ -840,6 +840,24 @@ fn an_opacity_sort_objects_stores_the_frontmost_mark_it_changes() {
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.6));
}
/// A Sort Objects that gives series marks the opacities they already have changes nothing: the value the graph stores
/// stays and no undo step is made, even when the evenly stepped percentage differs from a mark's in the last digits.
#[test]
fn an_opacity_sort_objects_that_changes_no_mark_keeps_the_stored_opacity() {
let (mut s, id, _, marks) = column_marks();
s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 65})).unwrap();
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3));
// From left to right the columns have 30%, 65% and 100%, the values an even step from 30% to 100% gives them.
let undo = s.doc().unwrap().history.undo.len();
let ids = [marks[0].0, marks[1].0, marks[2].0];
s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity", "distribute": true})).unwrap();
let opacities: Vec<f32> = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect();
assert_eq!(opacities, [0.3, 0.65, 1.0]);
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3));
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
}
/// An object opacity, blend mode, fill, stroke and weight that Global Edit gives a mark of another series from the mark
/// that was edited are stored on the graph for that series in the same undo step, and every mark of the series has them
/// when the graph is drawn again.
+3 -2
View File
@@ -472,7 +472,7 @@ impl Session {
pub fn transparency_info(&self) -> TransparencyInfo {
match (self.active(), transparency_targets(self, &Value::Null)) {
(Some(st), Ok(ids)) => {
let points = super::paint::picked_mesh_points_of(self, &ids);
let points = self.mesh_points_picked();
TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points)
}
_ => TransparencyInfo::default(),
@@ -483,7 +483,8 @@ impl Session {
fn info(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let ids = transparency_targets(s, p)?;
let points = super::paint::picked_mesh_points_of(s, &ids);
// The points `transparency.set` with the same `ids` (or none) fades.
let points = if p.get("ids").is_none() && p.get("id").is_none() { s.mesh_points_picked() } else { super::paint::picked_mesh_points_of(s, &ids) };
let i = TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points);
Ok(json!({
"ids": i.ids.iter().map(|id| id.0).collect::<Vec<_>>(),
+74 -10
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
has_doc,
|s, p| set_paint(s, p, true)
),
@@ -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; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). 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 (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)} 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 (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
),
@@ -154,7 +154,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Fill and Stroke",
[],
None,
"{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy}",
"{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art; when that object is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso and no fill item is active, the fill is its first picked point's color), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy (a gradient mesh with points picked counts with their colors)}",
always,
proxies
),
@@ -179,9 +179,13 @@ impl Session {
/// The fill and stroke the Fill/Stroke proxies show: the Appearance panel's active item (in the
/// first selected object's own stack) for the proxy of its kind, else the first selected
/// object's (a group's first painted object's; type: its first run's), else the defaults for
/// new art.
/// new art. When the object whose fill the proxy shows is a gradient mesh with points picked
/// ([`Session::fill_mesh_points`]), the fill is its first picked point's color, which a solid
/// fill color changes.
pub fn proxy_paints(&self) -> (Paint, Paint) {
let points = self.fill_mesh_points();
let show = |stroke: bool| match self.proxy_source(stroke) {
Some((n, None)) if !stroke && let Some(c) = picked_colors(&n, &points).first() => Paint::solid(*c),
Some((n, item)) => proxy_paint(&n, stroke, item),
None if stroke => self.paint.stroke.clone(),
None => self.paint.fill.clone(),
@@ -273,6 +277,13 @@ impl DocState {
/// each selected object's own stack is compared. One walk of the document, so callers that ask
/// every frame cache it by revision.
pub fn proxy_mixed(&self, item: Option<usize>) -> (bool, bool) {
self.proxy_mixed_with(item, &[])
}
/// [`DocState::proxy_mixed`] with the gradient mesh points a solid fill color recolors
/// (`points`, see [`Session::fill_mesh_points`]): a mesh among them counts with the colors of
/// its picked points instead of its fill.
fn proxy_mixed_with(&self, item: Option<usize>, points: &[(NodeId, Vec<usize>)]) -> (bool, bool) {
if self.selection.is_empty() {
return (false, false);
}
@@ -291,7 +302,10 @@ impl DocState {
let differ = |stroke: bool| {
let item = first.and_then(|f| f.appearance.item_of_kind(item, !stroke));
let set = if item.is_some() { &selected } else { &nodes };
let mut paints = set.iter().map(|n| proxy_paint(n, stroke, item));
let mut paints = set.iter().flat_map(|n| match picked_colors(n, if stroke || item.is_some() { &[] } else { points }) {
c if c.is_empty() => vec![proxy_paint(n, stroke, item)],
c => c.into_iter().map(Paint::solid).collect(),
});
let Some(p0) = paints.next() else { return false };
paints.any(|p| !same_in_proxy(&p0, &p))
};
@@ -313,8 +327,9 @@ impl DocState {
impl Session {
/// [`DocState::proxy_mixed`] of the active document for the Appearance panel's active item.
/// A gradient mesh with points picked ([`Session::fill_mesh_points`]) counts with their colors.
pub fn proxy_mixed(&self) -> (bool, bool) {
self.active().map_or((false, false), |st| st.proxy_mixed(self.appearance_item()))
self.active().map_or((false, false), |st| st.proxy_mixed_with(self.appearance_item(), &self.fill_mesh_points()))
}
}
@@ -539,8 +554,8 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
let selected = item.targets(s, p)?;
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint };
// The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill
// recolours just those points of their meshes.
// The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso: a solid
// fill recolors just those points of their meshes (the proxies show them: `fill_mesh_points`).
let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] };
// Without `item`, unless the Appearance panel's active fill item takes the paint.
let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top {
@@ -871,6 +886,55 @@ pub(crate) fn picked_mesh_points_of(s: &Session, ids: &[NodeId]) -> Option<Vec<(
(!ids.is_empty() && ids.iter().all(|id| v.iter().any(|(m, _)| m == id))).then_some(v)
}
/// The colors of the picked points of `n` (see [`picked_mesh_points`]) when it is a gradient mesh
/// among `points`, in the order they were picked; empty otherwise.
fn picked_colors(n: &Node, points: &[(NodeId, Vec<usize>)]) -> Vec<Color> {
match (&n.kind, points.iter().find(|(id, _)| *id == n.id)) {
(NodeKind::Mesh(m), Some((_, v))) => v.iter().filter_map(|i| m.points.get(*i)).map(|q| q.color).collect(),
_ => vec![],
}
}
impl Session {
/// The picked gradient mesh points that a solid fill color (`paint.setFill` without `ids` or
/// `item`) recolors instead of their meshes' fills, each mesh with its points: the targets are
/// resolved as [`set_paint`] resolves them, so this is empty while the Appearance panel's
/// active item is a fill row or an object of a symbol instance is selected, and a mesh with
/// points picked counts even when other objects are selected with it.
pub fn fill_mesh_points(&self) -> Vec<(NodeId, Vec<usize>)> {
let Some(st) = self.active() else { return vec![] };
// Only gradient meshes have points to pick: a selection with no mesh and no container that
// could hold one skips reading the tool's options.
let may_hold_mesh = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)) || n.is_container());
if st.selection.symbol_part.is_some() || !st.selection.subjects().iter().any(may_hold_mesh) {
return vec![];
}
let Ok(item) = item_target(self, &Value::Null, "paint.setFill").map(|t| t.of_kind(self, true)) else { return vec![] };
if item != ItemTarget::Top {
return vec![];
}
let Ok(selected) = item.targets(self, &Value::Null) else { return vec![] };
picked_mesh_points(self, &super::graph::paint_targets(&st.doc, &selected))
}
/// The picked gradient mesh points that the Transparency panel's opacity (`transparency.set`
/// without `ids` or `item`) fades instead of their objects: `None` unless every target is a
/// gradient mesh with points picked, and while the Appearance panel's active item takes the
/// opacity (outside opacity mask editing).
pub fn mesh_points_picked(&self) -> Option<Vec<(NodeId, Vec<usize>)>> {
let st = self.active()?;
let ids = super::opacitymask::transparency_targets(self, &Value::Null).ok()?;
// Only gradient meshes have points to pick: other targets skip reading the tool's options.
if ids.is_empty() || !ids.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Mesh(_)))) {
return None;
}
if st.doc.mask_edit.is_none() && self.appearance_item().is_some() {
return None;
}
picked_mesh_points_of(self, &ids)
}
}
/// A type run's stroke; a painted stroke on a run without one gets 1 pt.
pub(super) fn set_run_stroke(style: &mut CharStyle, paint: Paint) {
if !paint.is_none() {
@@ -1086,8 +1150,8 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
q.remove("item");
q.remove("id");
q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>()));
// The gradient mesh points picked with the Mesh tool or Direct Selection take the opacity
// themselves, so only the mesh around them fades (#4206, #4170, #4208).
// The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso take the
// opacity themselves, so only the mesh around them fades (#4206, #4170, #4208).
if matches!(item, ItemTarget::Top)
&& let Some(o) = q.get("opacity").and_then(Value::as_f64)
&& let Some(points) = picked_mesh_points_of(s, &ids)
+16 -14
View File
@@ -77,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reverse Path Direction",
["Object", "Path"],
None,
"{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}",
"{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step; when every subpath of a path turns alike, its strokes' width profiles (sides swapped) and pen pressure, tilt, bearing and rotation turn with it, so they draw as before → {changed: paths}",
has_selected_path,
reverse
),
@@ -462,22 +462,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
let mut changed = 0;
for id in &ids {
let path = path_mut(d, *id)?;
if let Some(si) = subpath {
if let Some(sp) = path.subpaths.get_mut(si) {
let flip: Vec<bool> = match subpath {
Some(si) => (0..path.subpaths.len()).map(|i| i == si).collect(),
None => path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(),
};
if !flip.contains(&true) {
continue;
}
// The stroke's width profile and pen recordings turn with the path, read before it turns.
if let Some(n) = d.node_mut(*id) {
vectorcraft_doc::blend::reverse_stroke_records(n, &flip);
}
let path = path_mut(d, *id)?;
for (sp, f) in path.subpaths.iter_mut().zip(flip) {
if f {
sp.reverse();
changed += 1;
}
} else {
let flip: Vec<bool> = path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect();
if flip.contains(&true) {
for (sp, f) in path.subpaths.iter_mut().zip(flip) {
if f {
sp.reverse();
}
}
changed += 1;
}
}
changed += 1;
}
Ok(changed)
})?;
+1 -1
View File
@@ -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; 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 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",
has_doc,
place
),
+16 -3
View File
@@ -3,6 +3,7 @@
use serde_json::Value;
use super::super::utf16_order_without_mark;
use super::{Point, Rect, Result, bad, bool_or, fileio, shapes, str_param};
/// Text files as `file.place` reports them (not a format `document.open` reads).
@@ -76,8 +77,10 @@ pub(super) fn import(bytes: &[u8], o: TextOptions, cmd: &str) -> Result<String>
Ok(clean(&decode(bytes, o), o))
}
/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark; bytes that aren't UTF-8 are
/// read as the platform's 8-bit set), or the platform's 8-bit character set.
/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark, or without one when the
/// file has an even length, reads as valid UTF-16 with no NUL character in the byte order
/// [`utf16_order_without_mark`] gives; bytes that aren't UTF-8 are read as the platform's 8-bit
/// set), or the platform's 8-bit character set.
fn decode(bytes: &[u8], o: TextOptions) -> String {
let eight_bit = |b: &[u8]| -> String { b.iter().map(|&c| if o.mac { mac_roman(c) } else { windows_1252(c) }).collect() };
if o.ansi {
@@ -91,7 +94,17 @@ fn decode(bytes: &[u8], o: TextOptions) -> String {
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
[0xFF, 0xFE, rest @ ..] => utf16(rest, true),
[0xFE, 0xFF, rest @ ..] => utf16(rest, false),
_ => std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string),
_ => {
let without_mark = (bytes.len().is_multiple_of(2) && bytes.contains(&0))
.then(|| utf16_order_without_mark(bytes))
.flatten()
.and_then(|le| {
let units = bytes.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
char::decode_utf16(units).collect::<std::result::Result<String, _>>().ok()
})
.filter(|text| !text.contains('\0'));
without_mark.unwrap_or_else(|| std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string))
}
}
}
+1
View File
@@ -26,6 +26,7 @@ use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids
mod csv;
mod library;
pub use csv::utf16_order_without_mark;
use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import};
pub(crate) use library::{give_svg_library, svg_library};
use library::{load_library, save_library};
+23 -13
View File
@@ -59,25 +59,35 @@ fn utf16(bytes: &[u8], le: bool) -> Option<String> {
char::decode_utf16(units).collect::<std::result::Result<String, _>>().ok().filter(|_| odd.is_empty())
}
/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16, or `None`. At least half of
/// the two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with
/// fewer, such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is
/// the one that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or
/// U+0D00, so a file that reads one in both orders is refused. A file that reads none in either
/// order has no data rows, and is read little-endian so that the import reports that. The text
/// must be valid UTF-16 in that order and hold no NUL character.
fn utf16_without_mark(bytes: &[u8]) -> Option<String> {
let (units, odd) = bytes.as_chunks::<2>();
if !odd.is_empty() || units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() {
/// The byte order of `bytes`, which hold NUL bytes and no byte-order mark, read as UTF-16:
/// `Some(true)` for little-endian, or `None` when they don't read as UTF-16. At least half of the
/// two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with fewer,
/// such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is the one
/// that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or U+0D00,
/// so a file that reads one in both orders is refused. A file that reads none in either order (one
/// line) is big-endian when more of its units start with a NUL byte than end with one, as
/// characters from U+0001 to U+00FF do in that order, and little-endian otherwise. An odd last
/// byte is left out of the count.
pub fn utf16_order_without_mark(bytes: &[u8]) -> Option<bool> {
let units = bytes.as_chunks::<2>().0;
if units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() {
return None;
}
let is_line_break = |u: u16| matches!(u, 0x0A | 0x0D);
let lines_le = units.iter().any(|&c| is_line_break(u16::from_le_bytes(c)));
let lines_be = units.iter().any(|&c| is_line_break(u16::from_be_bytes(c)));
if lines_le && lines_be {
return None;
match (lines_le, lines_be) {
(true, true) => None,
(false, false) => Some(units.iter().filter(|c| c[0] == 0).count() <= units.iter().filter(|c| c[1] == 0).count()),
(_, lines_be) => Some(!lines_be),
}
utf16(bytes, !lines_be).filter(|text| !text.contains('\0'))
}
/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16 in the byte order
/// [`utf16_order_without_mark`] gives, or `None`. The file must have an even length, and the text
/// must be valid UTF-16 in that order and hold no NUL character.
fn utf16_without_mark(bytes: &[u8]) -> Option<String> {
utf16(bytes, utf16_order_without_mark(bytes)?).filter(|text| !text.contains('\0'))
}
/// A row of a datasets CSV: the file line it starts on, its cells, and for each cell whether it was quoted.