Merge main into feat/cut-order

This commit is contained in:
dotCooCoo
2026-11-11 23:30:59 -08:00
103 changed files with 2972 additions and 353 deletions
+18 -2
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export DXF",
[],
None,
"{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused",
"{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; includeImages and includeHatches false, so fills are their outlines and no image files are written; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), includeImages?: true (false: placed images and their files are left out), includeHatches?: true (false: fills are their outlines, without solid hatches), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused",
has_doc,
export_dxf
)]
@@ -37,7 +37,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[
name: "dxfPreset",
ty: "string",
default: "\"custom\"",
description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it",
description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, no images, no fill hatches); the options given override it",
},
FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" },
FormatOption {
@@ -60,6 +60,18 @@ pub(crate) const OPTIONS: &[FormatOption] = &[
default: "\"png\"",
description: "png or jpeg: the files placed images are written to, next to the drawing",
},
FormatOption {
name: "includeImages",
ty: "boolean",
default: "true",
description: "placed images are written, linked to their files; false leaves them and their files out",
},
FormatOption {
name: "includeHatches",
ty: "boolean",
default: "true",
description: "filled areas get a solid hatch after their outlines; false writes the outlines alone",
},
FormatOption {
name: "preserve",
ty: "string",
@@ -133,6 +145,8 @@ struct Params {
scale_lineweights: Option<bool>,
colors: Option<Value>,
raster_format: Option<String>,
include_images: Option<bool>,
include_hatches: Option<bool>,
preserve: Option<String>,
alter_paths: Option<bool>,
outline_text: Option<bool>,
@@ -176,6 +190,8 @@ fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> {
scale_lineweights: q.scale_lineweights.unwrap_or(d.scale_lineweights),
colors: choice("colors", q.colors.as_ref().map(text), ColorDepth::from_id, d.colors, "8, 16, 256 or true")?,
raster: choice("rasterFormat", q.raster_format, RasterFormat::from_id, d.raster, "png or jpeg")?,
include_images: q.include_images.unwrap_or(d.include_images),
include_hatches: q.include_hatches.unwrap_or(d.include_hatches),
preserve: choice("preserve", q.preserve, Preserve::from_id, d.preserve, "appearance or editability")?,
alter_paths: q.alter_paths.unwrap_or(d.alter_paths),
outline_text: q.outline_text.unwrap_or(d.outline_text),
+47
View File
@@ -84,6 +84,11 @@ fn the_cnc_preset_sets_the_options_that_are_not_given() {
true
)
);
assert_eq!((o.include_images, o.include_hatches), (false, false), "CNC / CAM leaves out images and fill hatches");
let given = dxf::options(&json!({"dxfPreset": "cnc", "includeImages": true, "includeHatches": true})).unwrap();
assert_eq!((given.include_images, given.include_hatches), (true, true), "the options given override it");
let custom = dxf::options(&json!({})).unwrap();
assert_eq!((custom.include_images, custom.include_hatches), (true, true), "Custom keeps both");
assert_eq!(dxf::options(&json!({})).unwrap().version, vectorcraft_cad::DxfVersion::R2018, "custom by default");
let e = s.execute("document.exportDxf", &json!({"dxfPreset": "laser"})).unwrap_err().to_string();
assert!(e.contains("custom or cnc"), "{e}");
@@ -151,6 +156,44 @@ fn placed_images_come_back_as_linked_files() {
let _ = std::fs::remove_dir_all(dir);
}
/// `includeImages` and `includeHatches` leave out placed images (with their files) and fill
/// hatches, each on its own, through both commands, with and without a path; CNC / CAM leaves
/// out both, and the options given override it.
#[test]
fn placed_images_and_fill_hatches_can_be_left_out() {
let mut s = super::tests_svg::image_session();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 1, "height": 1})).unwrap();
let counts = |r: &Value| {
let e = entities(&text(r));
let n = |k: &str| e.iter().filter(|x| *x == k).count();
(n("LWPOLYLINE"), n("HATCH"), n("IMAGE"), r.get("linked").and_then(Value::as_array).map_or(0, Vec::len))
};
let warned = |r: &Value, w: &str| r["warnings"].as_array().unwrap().iter().any(|x| x.as_str().is_some_and(|x| x.starts_with(w)));
let all = s.execute("document.export", &json!({"format": "dxf"})).unwrap();
assert_eq!(counts(&all), (1, 1, 1, 1));
let r = s.execute("document.export", &json!({"format": "dxf", "includeImages": false})).unwrap();
assert_eq!(counts(&r), (1, 1, 0, 0));
assert!(warned(&r, "placed images are left out") && !warned(&r, "fill hatches"), "{r}");
let r = s.execute("document.exportDxf", &json!({"includeHatches": false})).unwrap();
assert_eq!(counts(&r), (1, 0, 1, 1));
assert!(warned(&r, "fill hatches are left out") && !warned(&r, "placed images"), "{r}");
let r = s.execute("document.exportDxf", &json!({"dxfPreset": "cnc"})).unwrap();
assert_eq!(counts(&r), (1, 0, 0, 0));
let r = s.execute("document.export", &json!({"format": "dxf", "dxfPreset": "cnc", "includeImages": true})).unwrap();
assert_eq!(counts(&r), (1, 0, 1, 1));
// With a path, no image file is written next to the drawing.
let dir = std::env::temp_dir().join(format!("vc-dxf-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("cut.dxf").to_string_lossy().to_string();
let r = s.execute("document.exportDxf", &json!({"path": path, "dxfPreset": "cnc"})).unwrap();
assert!(r.get("linked").is_none(), "{r}");
let files: Vec<String> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect();
assert_eq!(files, ["cut.dxf"]);
let _ = std::fs::remove_dir_all(dir);
}
/// Two placed images whose pixel data can't be decoded are left out of the drawing and get no
/// linked file, and `warnings` holds the note raster exports give, once, without naming either
/// image's key.
@@ -181,6 +224,8 @@ fn bad_dxf_options_are_refused() {
json!({"rasterFormat": "gif"}),
json!({"preserve": "both"}),
json!({"alterPaths": "yes"}),
json!({"includeImages": "no"}),
json!({"includeHatches": 0}),
] {
let e = s.execute("document.exportDxf", &p).unwrap_err().to_string();
assert!(e.contains("DXF"), "{p}: {e}");
@@ -203,6 +248,8 @@ fn formats_list_dxf_and_what_can_not_be_written() {
"scaleLineweights",
"colors",
"rasterFormat",
"includeImages",
"includeHatches",
"preserve",
"alterPaths",
"outlineText",
+56 -7
View File
@@ -1,5 +1,7 @@
use std::collections::BTreeSet;
use vectorcraft_doc::live::BlendSpineMode;
use super::common::{
DEFAULT_DISTANCE, DEFAULT_STEPS, ease_param, fix_ids, ids_json, is_blend, orientation_param, percent_param, roots_param, selected_of,
spacing_param, starts_param, wrap,
@@ -61,6 +63,19 @@ fn extend_spine(spec: &mut BlendSpec, old: &[Arc<Node>], old_spec: &BlendSpec, k
spec.spine = Some(path);
}
/// Does `p` set easing (`easingType` or `ramp`)? Easing needs a spine.
fn gives_easing(p: &Value) -> bool {
["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null()))
}
/// The Create Spine mode `p` gives as `key`, or `current` when it is omitted or null.
fn spine_mode_param(p: &Value, cmd: &str, key: &str, current: BlendSpineMode) -> Result<BlendSpineMode> {
match p.get(key).filter(|v| !v.is_null()) {
None => Ok(current),
Some(v) => v.as_str().and_then(BlendSpineMode::from_id).ok_or_else(|| bad(cmd, format!("`{key}` must be one of auto, always, never"))),
}
}
pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.make";
let roots = roots_param(s, p)?;
@@ -69,16 +84,22 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
}
let starts = starts_param(p, &roots)?;
let base = first_blend(&s.doc()?.doc, &roots);
let defaults = s.prefs.blend_options.unwrap_or_default();
// Create Spine applies to a new blend; one taking more keys keeps whether it has a spine.
let create_spine = spine_mode_param(p, C, "createSpine", defaults.create_spine)?;
let cur = match base.as_ref().map(|n| &n.kind) {
Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation },
_ => s.prefs.blend_options.unwrap_or_default(),
Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation, ..defaults },
_ => defaults,
};
let spacing = spacing_param(p, C, cur.spacing)?;
let orientation = orientation_param(p, cur.orientation);
let (ease0, color0) = match base.as_ref().map(|n| &n.kind) {
Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift),
_ => Default::default(),
let (ease0, color0, no_spine) = match base.as_ref().map(|n| &n.kind) {
Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift, spec.no_spine),
_ => (Default::default(), 0.0, !create_spine.creates_spine()),
};
if no_spine && gives_easing(p) {
return Err(bad(C, "the blend has no spine"));
}
let ease = ease_param(p, C, ease0)?;
let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?;
let id = s.edit("Make Blend", |d, sel| {
@@ -91,7 +112,7 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
n.knockout = vectorcraft_doc::Knockout::On;
n
});
let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() };
let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, no_spine, ..Default::default() };
if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind {
extend_spine(&mut spec, old, old_spec, &children);
}
@@ -346,9 +367,12 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
// Nothing to set them on: the options new blends start with (easing is per blend).
let d = s.prefs.blend_options.unwrap_or_default();
let spacing = spacing_param(p, C, d.spacing)?;
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) });
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation), ..d });
return Ok(json!({ "defaults": true }));
};
if gives_easing(p) && any_without_spine(s) {
return Err(bad(C, "the blend has no spine"));
}
let spacing = spacing_param(p, C, cur.spacing)?;
let orientation = orientation_param(p, cur.orientation);
let ease = ease_param(p, C, cur.ease)?;
@@ -384,6 +408,7 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
v["colorShift"] = json!(spec.color_shift);
v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>());
v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>());
v["hasSpine"] = json!(!spec.no_spine);
if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind)
&& let Some((path, anchors)) = live::blend_spine(children, &spec)
&& let Some(sp) = path.subpaths.first()
@@ -403,6 +428,19 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
})
}
/// The Blend panel's Create Spine: with `mode`, sets whether new blends get a spine; without, reads
/// it. A tool setting kept with the preferences, not an undo step; blends already made keep theirs.
pub(super) fn blend_create_spine(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.createSpine";
let mut d = s.prefs.blend_options.unwrap_or_default();
let mode = spine_mode_param(p, C, "mode", d.create_spine)?;
if mode != d.create_spine {
d.create_spine = mode;
s.prefs.blend_options = Some(d);
}
Ok(json!({ "mode": mode.id() }))
}
pub(super) fn blend_expand(s: &mut Session, _: &Value) -> Result<Value> {
let blends = selected_of(s, is_blend);
let ids = s.edit("Expand Blend", |d, sel| {
@@ -507,6 +545,8 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result<Value> {
let spine = d.node(path).and_then(|n| n.path_data().cloned()).ok_or(EngineError::NoNode(path))?;
d.remove(path)?;
if let Some(NodeKind::Blend { children, spec }) = d.node_mut(b).map(|n| &mut n.kind) {
// A blend made without a spine gets this one.
spec.no_spine = false;
spec.spine = Some(spine);
spec.key_anchors.clear();
keys_to_spine(children, spec);
@@ -517,7 +557,16 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result<Value> {
ok()
}
/// Is any selected blend one made without a spine?
fn any_without_spine(s: &Session) -> bool {
let Ok(st) = s.doc() else { return false };
selected_of(s, is_blend).iter().any(|b| matches!(st.doc.node(*b).map(|n| &n.kind), Some(NodeKind::Blend { spec, .. }) if spec.no_spine))
}
pub(super) fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result<Value> {
if any_without_spine(s) {
return Err(bad("object.blend.reverseSpine", "the blend has no spine"));
}
edit_blends(s, "Reverse Spine", |children, spec| {
if spec.spine.is_some() && pin(children, spec) {
// The keys keep their anchors, which now count from the other end.
+16 -7
View File
@@ -1,6 +1,6 @@
use super::blend::{
blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, blend_reverse_stack,
spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment,
blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine,
blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment,
};
use super::common::{has_blend, has_envelope, has_mesh};
use super::envelope::{
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Blend"],
Some("Cmd+Alt+B"),
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even), createSpine?: auto|always|never (default: the mode object.blend.createSpine sets; never: a blend without a spine, which takes no easing)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name, transparency and whether it has a spine) instead of nesting → {id}",
has_doc,
blend_make
),
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Release",
["Object", "Blend"],
Some("Cmd+Alt+Shift+B"),
"{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}",
"{} release the selected blends: the key objects come back and the spine (if the blend has one) stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}",
has_blend,
blend_release
),
@@ -40,6 +40,15 @@ pub fn specs() -> Vec<CommandSpec> {
always,
blend_options
),
cmd!(
"object.blend.createSpine",
"Create Spine",
[],
None,
"{mode?: auto|always|never} the Blend panel's Create Spine: whether new blends get a spine (auto and always: the straight lines between the key centers, as before; never: no spine to show, edit, reverse or release, and no easing, until Replace Spine gives one); with no `mode`, reads it; blends already made keep theirs (a tool setting kept with the preferences, not an undo step) → {mode}",
always,
blend_create_spine
),
cmd!(
"object.blend.expand",
"Expand",
@@ -54,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Replace Spine",
["Object", "Blend"],
None,
"{} use the selected path as the spine of the selected blend (the path is consumed)",
"{} use the selected path as the spine of the selected blend (the path is consumed); a blend made without a spine gets this one",
has_blend,
blend_replace_spine
),
@@ -63,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reverse Spine",
["Object", "Blend"],
None,
"{} reverse the order of the key objects along the spine",
"{} reverse the order of the key objects along the spine; an error when a selected blend has no spine",
has_blend,
blend_reverse_spine
),
@@ -72,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Info",
[],
None,
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], hasSpine?: false for a blend made without a spine (no `spine` then), spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
always,
blend_info
),
+1 -1
View File
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reflect…",
["Object", "Transform"],
None,
"{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg, 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), 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.",
has_selection,
reflect
),
+6 -4
View File
@@ -760,14 +760,16 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
// When the write failed, the old stored module stays in the folder and needs its grant at
// the next launch. A grant that can't be removed still approves the old bytes, so the store
// gets the module it held back. When that module can't be read or written back, or there
// was none, no module goes with the grant, and the grant stops counting for this session. A
// grants file that is too large or doesn't hold a list approves nothing, and the new module
// stays; one that can't be opened or read may approve the old bytes once it reads again.
// was none, no module goes with the grant, and the grant stops counting for every session in
// the process attached to the folder. A grants file that is too large or doesn't hold a list
// approves nothing, and the new module stays; one that can't be opened or read may approve
// the old bytes once it reads again.
let writes = s.grant_writes();
if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) {
let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref()));
stored = !back && holds_new(s);
if !back || matches!(previous, Ok(None)) {
s.ignore_stored_grant(&m.id);
s.ignore_stored_grant(&m.id, writes);
}
}
}
@@ -676,6 +676,7 @@ pub(super) const DENIED: &[&str] = &[
"addon.pen.nextSegment",
"effect.refreshDeferred",
"graphicStyle.setOptions",
"object.blend.createSpine",
"object.blend.options",
"object.liquify",
"object.pathfinder.options",
+40 -6
View File
@@ -769,7 +769,7 @@ impl Session {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}", always, get),
cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}; under automation read roots a folder preference outside them is null, and an export preset's folder outside them is left out", always, get),
cmd!(
query "prefs.set",
"Set Preference",
@@ -780,7 +780,7 @@ pub fn specs() -> Vec<CommandSpec> {
set
),
cmd!(query "prefs.reset", "Reset Preferences", [], None, "{category?} reset one category (or everything) to defaults", always, reset),
cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference", always, list),
cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference; under automation read roots a folder preference's value outside them is null", always, list),
cmd!(
query "prefs.export",
"Export Preferences",
@@ -886,8 +886,42 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "imported": values.len() }))
}
/// The preferences that name a folder: the [`FOLDER_PREFS`] and the scratch disks.
const PATH_PREFS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
/// The scratch disk choices that are not folders.
const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"];
/// The preferences as `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show them to the
/// request being served. Under automation read roots, a folder preference ([`PATH_PREFS`]) outside
/// them is `null`, as `ui.inspect` leaves it out, and an export preset's `folder` outside them is
/// left out. An empty value (the default folder) and a scratch
/// disk choice ([`SCRATCH_CHOICES`]) are shown. Without roots the preferences are shown as they are.
fn shown_prefs(prefs: &Prefs) -> Value {
let mut all = prefs.to_json();
if crate::file_access::current().is_none() {
return all;
}
// The app trims a folder before it uses it.
let hidden = |path: &str| !path.trim().is_empty() && crate::file_access::check_read(path.trim()).is_err();
for key in PATH_PREFS {
let Some(path) = all[key].as_str() else { continue };
if hidden(path) && !(key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path.trim())) {
all[key] = Value::Null;
}
}
if let Some(presets) = all.get_mut("screenExportPresets").and_then(Value::as_array_mut) {
for settings in presets.iter_mut().filter_map(|p| p.get_mut("settings").and_then(Value::as_object_mut)) {
if settings.get("folder").and_then(Value::as_str).is_some_and(hidden) {
settings.remove("folder");
}
}
}
all
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
let all = s.prefs.to_json();
let all = shown_prefs(&s.prefs);
match str_param(p, "key") {
Some(k) if spec(k).is_some() || PREF_GROUPS.contains(&k) => Ok(all[k].clone()),
Some(k) => Err(bad("prefs.get", format!("unknown preference `{k}`"))),
@@ -915,7 +949,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
if document_units && values.contains_key("unitsGeneral") && s.active().is_some() {
s.set_document_units(s.default_units())?;
}
let all = s.prefs.to_json();
let all = shown_prefs(&s.prefs);
Ok(Value::Object(values.keys().map(|k| (k.clone(), all[k.as_str()].clone())).collect()))
}
@@ -929,11 +963,11 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
let mut next = s.prefs.clone();
next.reset(cat);
s.apply_prefs(next);
Ok(json!({ "prefs": s.prefs.to_json() }))
Ok(json!({ "prefs": shown_prefs(&s.prefs) }))
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let all = s.prefs.to_json();
let all = shown_prefs(&s.prefs);
Ok(Value::Array(
PREF_SPECS
.iter()
@@ -140,6 +140,35 @@ fn block_turns_off_keeping_its_sizes_and_records_nothing_when_nothing_changes()
assert_eq!(undo_len(&s), n + 2, "already off: no undo step");
}
#[test]
fn convert_to_point_type_turns_block_off_keeping_its_sizes() {
let mut s = session();
let id = area(&mut s, 300.0, 400.0, "BIG\na much longer second line");
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let sizes = |t: &TextObject| t.runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.leading)).collect::<Vec<_>>();
let fitted = sizes(&text(&s, id));
let n = undo_len(&s);
s.execute("type.convertToPointType", &json!({})).unwrap();
assert_eq!(undo_len(&s), n + 1, "one undo step");
let t = text(&s, id);
assert!(matches!(t.kind, TextKind::Point));
assert!(!t.dynamic_text, "point type has Dynamic Text off");
assert_eq!(sizes(&t), fitted, "point type keeps the sizes Block set");
assert!(serde_json::to_value(&t).unwrap().get("dynamicText").is_none(), "saved without dynamicText");
// Back to area type, the frame is not refitted: Block stays off.
s.execute("type.convertToAreaType", &json!({})).unwrap();
let t = text(&s, id);
assert!(matches!(t.kind, TextKind::Area { .. }));
assert!(!t.dynamic_text, "Block does not come on again");
assert_eq!(sizes(&t), fitted);
// Undo gives the Block frame back.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let t = text(&s, id);
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
assert_eq!(sizes(&t), fitted);
}
#[test]
fn bad_params_and_other_type_are_refused() {
let mut s = session();
+4 -1
View File
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert To Point Type",
["Type"],
None,
"{ids?|id?} area type → point type; soft line wraps become line breaks, and a frame of threaded text leaves its thread with the text it holds → {ids}",
"{ids?|id?} area type → point type; soft line wraps become line breaks, a frame of threaded text leaves its thread with the text it holds, and Dynamic Text (Block) is turned off, keeping the sizes it set → {ids}",
has_selection,
to_point
),
@@ -410,6 +410,9 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1,
_ => x0,
};
// Point type has no Dynamic Text: Block is turned off, keeping the sizes it set, so
// converting back to area type does not turn it on again.
t.dynamic_text = false;
t.kind = TextKind::Point;
t.xf *= Affine::translate((ox, baseline));
refresh_bounds(t);