Files
designcraft/crates/engine/src/cmd/object.rs
T

5716 lines
274 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Object creation and the Object menu: frames, lines, transforms, arrange, group, lock/hide,
//! fill/stroke, content type, Text Frame Options, corners, fitting, text wrap.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use designcraft_color::BlendMode;
use designcraft_doc::{
AssetId, Content, Document, Effects, Fill, Item, ItemId, ItemLoc, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame,
TextFrameOptions, TextSel,
};
use designcraft_geom::{Affine, Point, Rect, Vec2, shapes};
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_doc, has_selection, ok, point_param, rect_param, spread_param, str_param, targets};
use crate::{Result, Session};
/// `name` must be a swatch of the document: an unknown name would paint nothing. The error names
/// the swatch it was probably meant to be (`Paper` → `[Paper]`, a different case).
pub(crate) fn known_swatch(doc: &designcraft_doc::Document, name: &str, cmd: &str, key: &str) -> Result<()> {
if doc.swatch(name).is_some() {
return Ok(());
}
let bare = |n: &str| n.trim().trim_start_matches('[').trim_end_matches(']').to_lowercase();
let hint = doc.swatches.iter().find(|s| bare(&s.name) == bare(name)).map(|s| format!(" (did you mean `{}`?)", s.name)).unwrap_or_default();
Err(bad(cmd, format!("{key}: no swatch named `{name}`{hint}; create one with `swatch.create` and use its returned name")))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"frame.create",
"Create Frame",
[],
None,
"{spread?: index | {kind: doc|parent, index} (a parent spread: index into `parents` of document.inspect), rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, starInset?: 0–100 (%), text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}",
has_doc,
frame_create
),
cmd!("line.create", "Create Line", [], None, "{spread?, a: [x,y], b: [x,y]}", has_doc, line_create),
cmd!(
"transform.move",
"Move",
["Object", "Transform"],
None,
"{dx, dy, copy?: bool, ids?, toSpread?, content?: bool (move the placed graphic or pasted-in items inside each frame; the frame stays. Default: whether the selection is content, when neither ids nor copy is given)} → {moved, ids: the objects moved (with copy, the copies)}",
has_selection,
transform_move
),
cmd!(
"transform.resize",
"Resize",
[],
None,
"{from: rect, to: rect, content?: bool (scale content), distribute?: bool (Live Distribute: objects keep their size, their centres spread with the bounds), ids?}",
has_selection,
transform_resize
),
cmd!(
"transform.rotate",
"Rotate",
["Object", "Transform"],
None,
"{angle (degrees, CCW), ref?: 0..8 (reference point of the selection bounds, default 4: centre), ids?}",
has_selection,
transform_rotate
),
cmd!("transform.scale", "Scale", ["Object", "Transform"], None, "{sx, sy, ids?} (about the selection centre)", has_selection, |s, p| {
let sx = f64_or(p, "sx", 1.0);
let sy = f64_or(p, "sy", sx);
if sx.abs() < 1e-4 || sy.abs() < 1e-4 {
return Err(bad("transform.scale", "scale too small"));
}
let strokes = s.prefs.scale_strokes;
let ids = targets(s, p)?;
s.edit(|d, _| {
let c = union_bounds(d, &ids).center();
let m = Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-c.to_vec2());
for id in &ids {
scale_object(d, *id, m, (sx * sy).abs().sqrt(), strokes);
}
ok()
})
}),
cmd!(
"transform.again",
"Transform Again",
["Object", "Transform Again"],
Some("Cmd+Alt+3"),
"{individually?: bool, sequence?: bool, ids?} — repeat the last transform (or the whole sequence applied to this selection), on the selection as a whole or on each object about its own centre",
has_selection,
transform_again
),
cmd!(
"transform.clear",
"Clear Transformations",
["Object", "Transform"],
None,
"{ids?} — remove rotation, shear and scaling (the object keeps its centre)",
has_selection,
clear_transformations
),
cmd!(
"transform.shear",
"Shear",
["Object", "Transform"],
None,
"{angle (degrees), ref?: 0..8 (reference point of the selection bounds, default 4: centre), ids?}",
has_selection,
|s, p| {
let a = f64_or(p, "angle", 0.0).clamp(-85.0, 85.0).to_radians().tan();
apply_about_ref(s, p, Affine::new([1.0, 0.0, a, 1.0, 0.0, 0.0]))
}
),
cmd!(
"object.matchAttributes",
"Apply Attributes (Eyedropper)",
[],
None,
"{from: id, ids?} — copy fill, stroke, corners, opacity, effects, wrap",
has_selection,
match_attributes
),
cmd!(
"transform.flip",
"Flip",
["Object", "Transform"],
None,
"{axis: horizontal|vertical, ref?: 0..8 (reference point of the selection bounds, default 4: centre), ids?}",
has_selection,
|s, p| {
let h = str_param(p, "axis") != Some("vertical");
apply_about_ref(s, p, if h { Affine::scale_non_uniform(-1.0, 1.0) } else { Affine::scale_non_uniform(1.0, -1.0) })
}
),
cmd!(
"transform.set",
"Transform Panel",
[],
None,
"{x?, y? (measured from the rulers' zero point, view.zeroPoint), width?, height?, scaleX? (%), scaleY? (%), rotation? (°), shear? (°), ref?: 0..8, ids?, content?: bool (the placed graphic inside each frame; the frame stays. Default: whether the selection is content, when no ids are given), bounds?: [x0, y0, x1, y1] (content: the graphic's new bounds on the spread, as its handles drag it)} — reference-point based geometry; rotation and shear are absolute (Transformations are Totals decides whether nested objects measure them on the pasteboard)",
has_selection,
transform_set
),
cmd!(query "transform.info", "Transform Values", [], None, "{ids?} → {scaleX, scaleY (%), rotation, shear (°), content?: the same for a placed graphic} of the first target", has_selection, transform_info),
cmd!("object.arrange", "Arrange", ["Object", "Arrange"], None, "{to: front|forward|backward|back, ids?}", has_selection, arrange),
cmd!("object.bringToFront", "Bring to Front", ["Object", "Arrange"], Some("Cmd+Shift+]"), "{}", has_selection, |s, _| s
.execute("object.arrange", &json!({"to": "front"}))),
cmd!("object.bringForward", "Bring Forward", ["Object", "Arrange"], Some("Cmd+]"), "{}", has_selection, |s, _| s
.execute("object.arrange", &json!({"to": "forward"}))),
cmd!("object.sendBackward", "Send Backward", ["Object", "Arrange"], Some("Cmd+["), "{}", has_selection, |s, _| s
.execute("object.arrange", &json!({"to": "backward"}))),
cmd!("object.sendToBack", "Send to Back", ["Object", "Arrange"], Some("Cmd+Shift+["), "{}", has_selection, |s, _| s
.execute("object.arrange", &json!({"to": "back"}))),
cmd!("object.group", "Group", ["Object"], Some("Cmd+G"), "{ids?}", can_group, group),
cmd!("object.ungroup", "Ungroup", ["Object"], Some("Cmd+Shift+G"), "{ids?}", has_selection, ungroup),
cmd!("object.lock", "Lock", ["Object"], Some("Cmd+L"), "{ids?}", has_selection, |s, p| set_flag(s, p, |i| i.locked = true, true)),
cmd!("object.unlockAll", "Unlock All on Spread", ["Object"], Some("Cmd+Alt+L"), "{}", has_doc, |s, _| all_flag(s, |i| i.locked = false)),
cmd!("object.hide", "Hide", ["Object"], Some("Cmd+3"), "{ids?}", has_selection, |s, p| set_flag(s, p, |i| i.hidden = true, true)),
cmd!("object.showAll", "Show All on Spread", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, |s, _| all_flag(s, |i| i.hidden = false)),
cmd!(
"object.setFlags",
"Set Visibility / Lock",
[],
None,
"{ids: [id], hidden?: bool, locked?: bool} — Layers panel eye/lock per object",
has_doc,
|s, p| {
let hidden = p.get("hidden").and_then(Value::as_bool);
let locked = p.get("locked").and_then(Value::as_bool);
set_flag(
s,
p,
move |i| {
if let Some(h) = hidden {
i.hidden = h;
}
if let Some(l) = locked {
i.locked = l;
}
},
hidden == Some(true) || locked == Some(true),
)
}
),
cmd!(
"object.fill",
"Fill",
[],
None,
"{swatch? (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?} — at least one; a tint alone keeps each object's own swatch (the Swatches panel's Tint field). With nothing selected: the fill of new shapes",
has_doc,
|s, p| {
let sw = str_param(p, "swatch").map(str::to_string);
let tint = match p.get("tint") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_f64().filter(|t| (0.0..=1.0).contains(t)).ok_or_else(|| bad("object.fill", "`tint` is 0..1"))? as f32),
};
match &sw {
Some(sw) => known_swatch(&s.doc()?.doc, sw, "object.fill", "swatch")?,
None if tint.is_none() => return Err(bad("object.fill", "missing swatch")),
None => {}
}
if nothing_targeted(s, p)? {
return set_new_object(s, |c| {
if let Some(sw) = &sw {
c.fill = sw.clone();
c.fill_tint = 1.0;
}
c.fill_tint = tint.unwrap_or(c.fill_tint);
});
}
set_flag(
s,
p,
move |i| match &sw {
Some(sw) => i.fill = Fill { swatch: sw.clone(), tint: tint.unwrap_or(1.0), ..i.fill.clone() },
None => i.fill.tint = tint.unwrap_or(i.fill.tint),
},
false,
)
}
),
cmd!(
"object.swapFillStroke",
"Swap Fill and Stroke",
[],
None,
"{ids?} — each object's fill and stroke colours (and tints) trade places; locked objects stay as they are. With nothing selected: those of new shapes",
has_doc,
|s, p| {
if nothing_targeted(s, p)? {
return set_new_object(s, |c| {
std::mem::swap(&mut c.fill, &mut c.stroke);
std::mem::swap(&mut c.fill_tint, &mut c.stroke_tint);
});
}
has_editable_selection(s).map_err(|e| bad("object.swapFillStroke", e))?;
edit_unlocked(s, p, swap_fill_stroke)
}
),
cmd!(
"object.defaultFillStroke",
"Default Fill and Stroke",
[],
None,
"{ids?} — no fill and a black stroke; locked objects stay as they are. With nothing selected: for new shapes",
has_doc,
|s, p| {
if nothing_targeted(s, p)? {
return set_new_object(s, |c| *c = designcraft_doc::NewObjectColors::default());
}
has_editable_selection(s).map_err(|e| bad("object.defaultFillStroke", e))?;
edit_unlocked(s, p, default_fill_stroke)
}
),
cmd!(
"object.stroke",
"Stroke",
[],
None,
"{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed|stripes|style, pattern?: [dash, gap, …] (dashed: required, points), bands?: [[start, width], …] (stripes: required, fractions of stroke weight), name?: string (style: required, document stroke style name)} (other kinds take no extra fields; unknown type fields are errors), cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}",
has_doc,
|s, p| {
let kind: Option<designcraft_doc::StrokeType> = p
.get("type")
.map(|v| {
if let Some(fields) = v.as_object() {
let field = match v.get("kind").and_then(Value::as_str) {
Some("dashed") => Some("pattern"),
Some("stripes") => Some("bands"),
Some("style") => Some("name"),
_ => None,
};
for key in fields.keys() {
if key != "kind" && Some(key.as_str()) != field {
return Err(bad("object.stroke", format!("type: unknown field `{key}`")));
}
}
}
serde_json::from_value(v.clone()).map_err(|e| bad("object.stroke", format!("type: {e}")))
})
.transpose()?;
for key in ["swatch", "gapSwatch"] {
if let Some(name) = p.get(key).and_then(Value::as_str) {
known_swatch(&s.doc()?.doc, name, "object.stroke", key)?;
}
}
// With nothing selected, the swatch and tint are those of new shapes.
if nothing_targeted(s, p)? {
let sw = str_param(p, "swatch").map(str::to_string);
let tint = p.get("tint").and_then(Value::as_f64).map(|t| t.clamp(0.0, 1.0) as f32);
if sw.is_none() && tint.is_none() {
return Err(bad("object.stroke", super::NOTHING_SELECTED));
}
return set_new_object(s, |c| {
if let Some(sw) = &sw {
c.stroke = sw.clone();
c.stroke_tint = 1.0;
}
c.stroke_tint = tint.unwrap_or(c.stroke_tint);
});
}
let p2 = p.clone();
// A tint alone (the Swatches panel's Tint field) recolours strokes, it doesn't add one.
let tint_only = p.as_object().is_some_and(|o| o.contains_key("tint") && o.keys().all(|k| k == "tint" || k == "ids"));
set_flag(
s,
p,
move |i| {
if tint_only && i.stroke.is_none() {
return;
}
let mut st = if i.stroke.is_none() && p2.get("swatch").is_none() { Stroke::default() } else { i.stroke.clone() };
if let Some(v) = p2.get("swatch").and_then(Value::as_str) {
st.swatch = v.into();
}
if let Some(v) = p2.get("tint").and_then(Value::as_f64) {
st.tint = v.clamp(0.0, 1.0) as f32;
}
if let Some(v) = p2.get("weight").and_then(Value::as_f64) {
st.weight = v.max(0.0);
}
if let Some(v) = p2.get("miterLimit").and_then(Value::as_f64) {
st.miter_limit = v.clamp(1.0, 500.0);
}
if let Some(v) = p2.get("gapSwatch").and_then(Value::as_str) {
st.gap_swatch = v.into();
}
if let Some(v) = p2.get("gapTint").and_then(Value::as_f64) {
st.gap_tint = v.clamp(0.0, 1.0) as f32;
}
if let Some(v) = p2.get("gapOverprint").and_then(Value::as_bool) {
st.gap_overprint = v;
}
if let Some(kind) = &kind {
st.kind = kind.clone();
}
for (k, apply) in [("align", 0), ("cap", 2), ("join", 3), ("start", 4), ("end", 5)] {
if let Some(v) = p2.get(k).cloned() {
let _ = match apply {
0 => serde_json::from_value(v).map(|x| st.align = x),
2 => serde_json::from_value(v).map(|x| st.cap = x),
3 => serde_json::from_value(v).map(|x| st.join = x),
4 => serde_json::from_value(v).map(|x| st.start = x),
_ => serde_json::from_value(v).map(|x| st.end = x),
};
}
}
i.stroke = st;
},
false,
)
}
),
cmd!("object.opacity", "Opacity", [], None, "{opacity: 0..1, blend?: normal|multiply|…, ids?}", has_selection, |s, p| {
let o = f64_or(p, "opacity", 1.0).clamp(0.0, 1.0) as f32;
// `BlendMode::parse` takes the documented lowercase forms (`multiply`), the panel
// labels (`Color Burn`) and the serialized names (`Multiply`). An unknown value is
// an error rather than a silent no-op that still reports `changed` (#260).
let blend = match p.get("blend") {
None | Some(Value::Null) => None,
Some(v) => {
let raw = v.as_str().ok_or_else(|| bad("object.opacity", "blend must be a string"))?;
Some(BlendMode::parse(raw).ok_or_else(|| bad("object.opacity", format!("unknown blend mode `{raw}`")))?)
}
};
set_flag(
s,
p,
move |i| {
i.opacity = o;
if let Some(b) = blend {
i.blend = b;
}
},
false,
)
}),
cmd!(
"object.dropShadow",
"Drop Shadow",
["Object", "Effects"],
Some("Cmd+Alt+M"),
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), ids?}",
has_selection,
|s, p| {
let p2 = p.clone();
set_effects(s, p, "object.dropShadow", move |fx| {
let ds = &mut fx.drop_shadow;
ds.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!ds.on);
ds.distance = f64_or(&p2, "distance", ds.distance);
ds.angle = f64_or(&p2, "angle", ds.angle);
ds.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(ds.global_light);
ds.opacity = f64_or(&p2, "opacity", ds.opacity as f64) as f32;
ds.size = f64_or(&p2, "size", ds.size);
ds.spread = f64_or(&p2, "spread", ds.spread);
if let Some(c) = str_param(&p2, "color") {
ds.color = c.into();
}
})
}
),
cmd!(
"object.innerShadow",
"Inner Shadow",
["Object", "Effects"],
None,
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, ids?}",
has_selection,
|s, p| {
let p2 = p.clone();
set_effects(s, p, "object.innerShadow", move |fx| {
let e = &mut fx.inner_shadow;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.distance = f64_or(&p2, "distance", e.distance);
e.angle = f64_or(&p2, "angle", e.angle);
e.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(e.global_light);
e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32;
e.size = f64_or(&p2, "size", e.size);
e.choke = f64_or(&p2, "choke", e.choke);
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
})
}
),
cmd!(
"object.outerGlow",
"Outer Glow",
["Object", "Effects"],
None,
"{on?: bool, opacity?, size?, spread? (%), color?, ids?}",
has_selection,
|s, p| {
let p2 = p.clone();
set_effects(s, p, "object.outerGlow", move |fx| {
let e = &mut fx.outer_glow;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32;
e.size = f64_or(&p2, "size", e.size);
e.spread = f64_or(&p2, "spread", e.spread);
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
})
}
),
cmd!(
"object.exportOptions",
"Object Export Options…",
["Object"],
None,
"{ids?, artifact?: bool (tagged PDF decoration), rasterize?: bool (EPUB/HTML image), align?: left|center|right|\"\", pageBreakBefore?: bool, altText?}",
has_selection,
|s, p| {
let ids = targets(s, p)?;
let p = p.clone();
s.edit(|d, _| {
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
let e = &mut it.export_options;
if let Some(v) = p.get("artifact").and_then(Value::as_bool) {
e.artifact = v;
}
if let Some(v) = p.get("rasterize").and_then(Value::as_bool) {
e.rasterize = v;
}
if let Some(v) = p.get("align").and_then(Value::as_str) {
e.align = v.to_string();
}
if let Some(v) = p.get("pageBreakBefore").and_then(Value::as_bool) {
e.page_break_before = v;
}
if let Some(v) = p.get("altText").and_then(Value::as_str) {
it.alt_text = v.to_string();
}
}
Ok(json!({"objects": ids.len()}))
})
}
),
cmd!(
"object.innerGlow",
"Inner Glow",
["Object", "Effects"],
None,
"{on?: bool, opacity?, size?, choke? (%), center?: bool (glow from the centre), color?, ids?}",
has_selection,
|s, p| {
let p2 = p.clone();
set_effects(s, p, "object.innerGlow", move |fx| {
let e = &mut fx.inner_glow;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32;
e.size = f64_or(&p2, "size", e.size);
e.choke = f64_or(&p2, "choke", e.choke);
e.center = p2.get("center").and_then(Value::as_bool).unwrap_or(e.center);
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
})
}
),
cmd!(
"object.bevel",
"Bevel and Emboss",
["Object", "Effects"],
None,
"{on?: bool, size?, depth? (%), angle?, globalLight?: bool, highlight?, highlightOpacity?, shadow?, shadowOpacity?, ids?}",
has_selection,
|s, p| {
let p2 = p.clone();
set_effects(s, p, "object.bevel", move |fx| {
let e = &mut fx.bevel;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.size = f64_or(&p2, "size", e.size);
e.depth = f64_or(&p2, "depth", e.depth);
e.angle = f64_or(&p2, "angle", e.angle);
e.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(e.global_light);
e.highlight_opacity = f64_or(&p2, "highlightOpacity", e.highlight_opacity as f64) as f32;
e.shadow_opacity = f64_or(&p2, "shadowOpacity", e.shadow_opacity as f64) as f32;
if let Some(c) = str_param(&p2, "highlight") {
e.highlight = c.into();
}
if let Some(c) = str_param(&p2, "shadow") {
e.shadow = c.into();
}
})
}
),
cmd!(
"object.satin",
"Satin",
["Object", "Effects"],
None,
"{on?: bool, opacity?, angle?, distance?, size?, invert?: bool, color?, ids?}",
has_selection,
|s, p| {
let p2 = p.clone();
set_effects(s, p, "object.satin", move |fx| {
let e = &mut fx.satin;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32;
e.angle = f64_or(&p2, "angle", e.angle);
e.distance = f64_or(&p2, "distance", e.distance);
e.size = f64_or(&p2, "size", e.size);
e.invert = p2.get("invert").and_then(Value::as_bool).unwrap_or(e.invert);
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
})
}
),
cmd!(
"object.directionalFeather",
"Directional Feather",
["Object", "Effects"],
None,
"{on?: bool, widths?: [top, left, bottom, right] | number, ids?}",
has_selection,
|s, p| {
let p2 = p.clone();
set_effects(s, p, "object.directionalFeather", move |fx| {
let e = &mut fx.directional_feather;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
match p2.get("widths") {
Some(Value::Number(n)) => e.widths = [n.as_f64().unwrap_or(0.0).max(0.0); 4],
Some(v @ Value::Array(_)) => {
if let Ok(w) = serde_json::from_value::<[f64; 4]>(v.clone()) {
e.widths = w.map(|x| x.max(0.0));
}
}
_ => {}
}
if e.on && e.widths.iter().all(|w| *w <= 0.0) {
e.widths = [9.0; 4];
}
})
}
),
cmd!(
"object.effects",
"Effects",
[],
None,
"{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, globalLight? (the document's global light angle, as an Angle field with Use Global Light sets it), ids?} — every effect at once, one undo step",
has_selection,
effects_all
),
cmd!("object.feather", "Basic Feather", ["Object", "Effects"], None, "{width (0 = off), ids?}", has_selection, |s, p| {
let w = f64_or(p, "width", 9.0).max(0.0);
set_effects(s, p, "object.feather", move |fx| fx.feather = w)
}),
cmd!("object.content", "Content", ["Object", "Content"], None, "{type: graphic|text|unassigned, ids?}", has_selection, content_type),
cmd!(
"object.textFrameOptions",
"Text Frame Options…",
["Object"],
Some("Cmd+B"),
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
has_selection,
text_frame_options
),
cmd!(
"object.cornerOptions",
"Corner Options…",
["Object"],
None,
"{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size}] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size), ids?}",
has_selection,
corner_options
),
cmd!(
"object.textWrap",
"Text Wrap",
["Window"],
Some("Cmd+Alt+W"),
"{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r], invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary",
has_selection,
text_wrap
),
cmd!(
"object.fit",
"Fitting",
["Object", "Fitting"],
None,
"{mode: fillProportionally (preserves aspect ratio, covers frame, clips overflow)|fitProportionally (preserves aspect ratio, shows whole image, may leave empty space)|fitContentToFrame (stretches content to frame)|centerContent (keeps current size, positions at fitting alignment)|fitFrameToContent (resizes frame to content), axis?: both|height|width (frame-local sides Fit Frame to Content moves; default both), ids?} — locked objects and objects on locked layers are left as they are",
has_selection,
fit
),
cmd!(
"object.fittingOptions",
"Frame Fitting Options…",
["Object", "Fitting"],
None,
"{autoFit?: bool, fitting?: none|fillProportionally|fitProportionally|fitContentToFrame|centerContent, align?: 0..8 (reference point, 4 = centre), crop?: n | [t, l, b, r] (negative adds space), ids?}",
has_selection,
fitting_options
),
cmd!("object.rename", "Rename", [], None, "{id | ids?, name}", has_doc, |s, p| {
let name = str_param(p, "name").unwrap_or("").to_string();
set_flag(s, p, move |i| i.name = name.clone(), false)
}),
cmd!(
"edit.stepAndRepeat",
"Step and Repeat…",
["Edit"],
Some("Cmd+Alt+U"),
"{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents → {created, ids: the copies}",
has_selection,
step_and_repeat
),
cmd!("path.create", "Create Path", [], None, "{spread?, anchors: [{p:[x,y], in?:[x,y], out?:[x,y]}], closed?: bool}", has_doc, path_create),
cmd!(
"path.appendAnchor",
"Add Anchor",
[],
None,
"{id, anchor: {p, in?, out?} (spread coords), subpath?: index (default the last), at?: end|start (default end)} — after the subpath's last anchor, or before its first with `at: start`",
has_doc,
path_append
),
cmd!(
"path.setAnchor",
"Set Anchor",
[],
None,
"{id, subpath?: index (default the last), anchor: index|\"first\"|\"last\", p?, in?, out? (spread coords)} — moves an anchor or its direction lines; with both lines given and collinear it becomes smooth",
has_doc,
path_set_anchor
),
cmd!(
"path.reverse",
"Reverse Path",
["Object", "Paths"],
None,
"{ids?, subpath?: index (only that subpath of the first target)} — the anchors run the other way (the end point becomes the start)",
has_selection,
path_reverse
),
cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close),
cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors),
cmd!(
"object.align",
"Align",
["Window", "Object & Layout", "Align"],
None,
"{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread (default: keyObject while a key object is set), ids?}",
has_selection,
align
),
cmd!(
"object.distribute",
"Distribute",
["Window", "Object & Layout", "Align"],
None,
"{axis: horizontal|vertical, by?: centers|spacing, spacing?: points, ids?}",
has_selection,
distribute
),
cmd!(
noundo "tool.polygonSettings",
"Polygon Settings…",
[],
None,
"{sides?: 3–100, starInset?: 0–100 (%)} → the settings new polygons use",
super::always,
|s, p| {
if let Some(n) = p.get("sides").and_then(Value::as_u64) {
s.prefs.polygon_sides = n.clamp(3, 100) as u32;
}
if let Some(v) = p.get("starInset").and_then(Value::as_f64) {
s.prefs.star_inset = (v / 100.0).clamp(0.0, 1.0);
}
Ok(json!({"sides": s.prefs.polygon_sides, "starInset": (s.prefs.star_inset * 100.0).round()}))
}
),
cmd!(
"gap.move",
"Move Gap",
[],
None,
"{at: [x, y] (spread), delta?: pt | dx? | dy?: a drag (the part along the gap's axis counts), spread?} — Gap tool: the gap at `at` between objects (or an object and the page edge) moves by `delta`; the objects on both sides resize → {axis, changed}",
has_doc,
gap_move
),
cmd!(
"frame.grid",
"Gridify",
[],
None,
"{rect, cols, rows, gutter? (pt, 12), shape?, content?, spread?} — a grid of frames filling `rect` (frame tools: arrow keys while dragging)",
has_doc,
|s, p| {
let rect = rect_param(p, "rect").ok_or_else(|| bad("frame.grid", "missing rect"))?;
let cols = p.get("cols").and_then(Value::as_u64).unwrap_or(1).clamp(1, 100) as usize;
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(1).clamp(1, 100) as usize;
let gutter = p.get("gutter").and_then(Value::as_f64).unwrap_or(12.0).max(0.0);
let cw = ((rect.width() - gutter * (cols - 1) as f64) / cols as f64).max(0.5);
let ch = ((rect.height() - gutter * (rows - 1) as f64) / rows as f64).max(0.5);
let mut ids = Vec::new();
for r in 0..rows {
for c in 0..cols {
let x0 = rect.x0 + c as f64 * (cw + gutter);
let y0 = rect.y0 + r as f64 * (ch + gutter);
let q = super::with_param(&super::with_param(p, "rect", json!([x0, y0, x0 + cw, y0 + ch])), "caret", json!(false));
ids.push(frame_create(s, &q)?["id"].clone());
}
}
let sel: Vec<ItemId> = ids.iter().filter_map(Value::as_u64).map(ItemId).collect();
s.doc_mut()?.selection = designcraft_doc::Selection::items(sel);
Ok(json!({"ids": ids}))
}
),
cmd!(
"object.clippingPath",
"Clipping Path…",
["Object", "Clipping Path"],
None,
"{type?: alpha|edges (default alpha), threshold?: 0–255 (alpha: opaque above; edges: darker than 255 − threshold counts; default 25), tolerance?: px (2), ids?} — traces the placed graphic and makes the outline the frame (Convert Clipping Path to Frame)",
has_selection,
clipping_path
),
cmd!(
"object.clippingPathOptions",
"Clipping Path Options…",
["Object", "Clipping Path"],
None,
"{type?: none, invert?: bool, includeInsideEdges?: bool, ids?} — without type, invert or includeInsideEdges, reports each selected graphic's clipping path ({items: [{id, type: none|detectEdges|alphaChannel|photoshopPath|userModifiedPath, anchors, invert, includeInsideEdges, threshold, tolerance, inset, sourceName}]}); type none removes it, invert shows the graphic outside it instead of inside",
has_selection,
clipping_path_options
),
cmd!(
"object.primaryTextFrame",
"Primary Text Frame",
["Object"],
None,
"{on?: bool (default: toggle), ids?} — the selected text frame's story becomes the primary story (Smart Text Reflow adds and removes pages for it)",
has_selection,
|s, p| {
let ids = targets(s, p)?;
let sid = ids
.iter()
.find_map(|id| s.doc().ok()?.doc.item(*id)?.text_frame().map(|t| t.story))
.ok_or_else(|| bad("object.primaryTextFrame", "select a text frame"))?;
let cur = s.doc()?.doc.settings.primary_story;
let on = p.get("on").and_then(Value::as_bool).unwrap_or(cur != Some(sid));
s.edit(|d, _| {
d.settings.primary_story = if on {
Some(sid)
} else if cur == Some(sid) {
None
} else {
cur
};
Ok(json!({"primary": on, "story": sid.0}))
})
}
),
cmd!(
"object.attributes",
"Attributes",
["Window", "Output", "Attributes"],
None,
"{overprintFill?, overprintStroke?, overprintGap?, nonprinting?: bool, ids?} — Attributes panel",
has_selection,
|s, p| {
let b = |k: &str| p.get(k).and_then(Value::as_bool);
let (of, os, og, np) = (b("overprintFill"), b("overprintStroke"), b("overprintGap"), b("nonprinting"));
set_flag(
s,
p,
move |i| {
if let Some(v) = of {
i.fill.overprint = v;
}
if let Some(v) = os {
i.stroke.overprint = v;
}
if let Some(v) = og {
i.stroke.gap_overprint = v;
}
if let Some(v) = np {
i.nonprinting = v;
}
},
false,
)
}
),
cmd!(
"object.globalLight",
"Global Light…",
["Object", "Effects"],
None,
"{angle} — the light angle shared by shadows that use Global Light",
has_doc,
|s, p| {
let a = p.get("angle").and_then(Value::as_f64).ok_or_else(|| bad("object.globalLight", "angle required"))?;
let a = (a + 180.0).rem_euclid(360.0) - 180.0;
s.edit(|d, _| {
d.settings.global_light = a;
Ok(json!({"angle": a}))
})
}
),
cmd!(
"object.label",
"Script Label",
["Window", "Utilities", "Script Label"],
None,
"{label, ids?} — free text scripts and agents can find objects by",
has_selection,
|s, p| {
let label = str_param(p, "label").unwrap_or("").to_string();
set_flag(s, p, move |i| i.label = label.clone(), false)
}
),
cmd!(
"object.altText",
"Object Export Options",
["Object"],
None,
"{text, ids?} — alternative text for tagged PDF and EPUB",
has_selection,
|s, p| {
let text = str_param(p, "text").unwrap_or("").to_string();
set_flag(s, p, move |i| i.alt_text = text.clone(), false)
}
),
cmd!(
"object.transparencyGroup",
"Group Transparency",
[],
None,
"{isolate?: bool, knockout?: bool, ids?} — Effects panel: Isolate Blending, Knockout Group",
has_selection,
|s, p| {
let iso = p.get("isolate").and_then(Value::as_bool);
let ko = p.get("knockout").and_then(Value::as_bool);
set_flag(
s,
p,
move |i| {
if let Some(v) = iso {
i.isolate = v;
}
if let Some(v) = ko {
i.knockout = v;
}
},
false,
)
}
),
cmd!(query "object.findByLabel", "Find by Script Label", [], None, "{label} → ids of objects whose label is `label`", has_doc, |s, p| {
let label = str_param(p, "label").unwrap_or("");
let d = &s.doc()?.doc;
let mut ids = Vec::new();
for sp in d.spreads.iter().chain(d.parents.iter()) {
for it in &sp.items {
it.walk(&mut |x| {
if x.label == label {
ids.push(x.id.0);
}
});
}
}
Ok(json!({"ids": ids}))
}),
cmd!(
"object.setLayer",
"Move to Layer",
[],
None,
"{layer, ids?} — a group member moves its whole top-level group",
has_selection,
set_layer
),
cmd!(
"object.reorder",
"Reorder Objects",
[],
None,
"{ids?, above?: id | below?: id | index?: n (0 = top of the layer), layer?} — moves the objects (siblings on one spread) in the stacking order: in front of `above` or behind `below` (onto that object's layer), or to `index` in `layer` counted from the top (default: the first object's layer, index 0). Group members stay in their group. Leaves the document (and undo history) alone when nothing would change.",
has_doc,
reorder
),
]
}
/// No objects named and none selected (and no text): fill and stroke go to new shapes.
pub(crate) fn nothing_targeted(s: &Session, p: &Value) -> Result<bool> {
let st = s.doc()?;
Ok(p.get("ids").is_none()
&& p.get("id").is_none()
&& st.selection.items.is_empty()
&& st.selection.text.is_none()
&& st.selection.cells.is_none())
}
/// Change the colours new shapes are drawn with (one undo step).
pub(crate) fn set_new_object(s: &mut Session, f: impl FnOnce(&mut designcraft_doc::NewObjectColors)) -> Result<Value> {
s.edit(|d, _| {
f(&mut d.settings.new_object);
let c = &d.settings.new_object;
Ok(json!({"newObject": {"fill": c.fill, "fillTint": c.fill_tint, "stroke": c.stroke, "strokeTint": c.stroke_tint}}))
})
}
/// A new shape's fill and stroke: the document's colours for new objects (a swatch deleted
/// since leaves the shape's own).
fn new_object_colors(d: &designcraft_doc::Document, it: &mut Item) {
let c = &d.settings.new_object;
let known = |sw: &str| sw == designcraft_color::swatch::NONE || d.swatches.iter().any(|w| w.name == sw);
if known(&c.fill) {
it.fill = Fill { swatch: c.fill.clone(), tint: c.fill_tint, ..it.fill.clone() };
}
if known(&c.stroke) {
it.stroke.swatch = c.stroke.clone();
it.stroke.tint = c.stroke_tint;
}
}
fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
let rect = rect_param(p, "rect").ok_or_else(|| bad("frame.create", "missing rect"))?;
let shape = str_param(p, "shape").unwrap_or("rectangle");
let content = str_param(p, "content").unwrap_or("graphic");
let sr = spread_param(p, "frame.create")?;
let lid = s.doc()?.active_layer;
let text = super::text_param(p, "text");
let caret = bool_or(p, "caret", content == "text");
let vertical = bool_or(p, "vertical", false);
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(u64::from(s.prefs.polygon_sides)).clamp(3, 100) as u32;
let inset = p.get("starInset").and_then(Value::as_f64).map_or(s.prefs.star_inset, |v| v / 100.0).clamp(0.0, 1.0);
let rect = Rect::new(rect.x0, rect.y0, rect.x1.max(rect.x0 + 0.5), rect.y1.max(rect.y0 + 0.5));
s.edit(|d, sel| {
if d.spread(sr).is_none() {
return Err(bad("frame.create", "no such spread"));
}
let (path, sh) = match shape {
"ellipse" | "oval" => (shapes::ellipse(rect), Shape::Oval),
"polygon" => (polygon_in(rect, sides, inset), Shape::Polygon),
_ => (shapes::rectangle(rect), Shape::Rectangle),
};
if content == "text" {
let (id, sid) = d.add_text_frame(sr, rect, lid, &text, ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() })?;
if let Some(it) = d.item_mut(id) {
it.path = path;
it.shape = sh;
}
super::text::set_story_direction(d, &[sid], vertical);
*sel = if caret {
Selection::text(TextSel { story: sid, anchor: text.len(), focus: text.len(), frame: Some(id), cell: None })
} else {
Selection::items(vec![id])
};
return Ok(json!({"id": id.0, "story": sid.0}));
}
let id = ItemId(d.alloc());
let mut it = Item::new(id, lid, sh, path);
if content == "graphic" {
it.object_style = d.styles.default_graphic_frame.clone();
if let Some(e) = d.styles.object_effects(&it.object_style) {
it.effects = e.clone();
}
} else {
// Shapes drawn with Rectangle/Ellipse/Polygon get a 1 pt stroke in the document's
// colours for new objects (no fill and black at first).
it.stroke = Stroke::default();
new_object_colors(d, &mut it);
}
d.insert_item(sr, it, None)?;
*sel = Selection::items(vec![id]);
Ok(json!({"id": id.0}))
})
}
/// A polygon (or, with a star inset, a star with `sides` points) fitted to `r`.
pub(crate) fn polygon_in(r: Rect, sides: u32, inset: f64) -> designcraft_geom::PathData {
let c = r.center();
let mut p =
if inset > 0.0 { shapes::star(Point::ZERO, 1.0, (1.0 - inset).max(0.0), sides, 0.0) } else { shapes::polygon(Point::ZERO, 1.0, sides, 0.0) };
// Fit the unit polygon's bounds to the rect.
if let Some(b) = p.bounds() {
let a = Affine::translate(c.to_vec2())
* Affine::scale_non_uniform(r.width() / b.width().max(1e-9), r.height() / b.height().max(1e-9))
* Affine::translate(-b.center().to_vec2());
p.transform(a);
}
p
}
fn line_create(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_param(p, "a").ok_or_else(|| bad("line.create", "missing a"))?;
let b = point_param(p, "b").ok_or_else(|| bad("line.create", "missing b"))?;
let sr = spread_param(p, "line.create")?;
let lid = s.doc()?.active_layer;
s.edit(|d, sel| {
let id = ItemId(d.alloc());
let mut it = Item::new(id, lid, Shape::GraphicLine, shapes::line(a, b));
it.stroke = Stroke::default();
new_object_colors(d, &mut it);
d.insert_item(sr, it, None)?;
*sel = Selection::items(vec![id]);
Ok(json!({"id": id.0}))
})
}
/// Deep-copy items `ids` from `src` into `dst` spread `to` (fresh ids; text frames get story copies).
pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: SpreadRef, off: Vec2) -> Result<Vec<ItemId>> {
let mut out = Vec::new();
let mut story_map: HashMap<StoryId, StoryId> = HashMap::new();
for id in ids {
let Some(it) = src.item(*id) else { continue };
let mut copy = it.clone();
copy.xf = Affine::translate(off) * copy.xf;
renumber(dst, src, &mut copy, &mut story_map);
if dst.layer(copy.layer).is_none() {
copy.layer = dst.default_layer();
}
let nid = copy.id;
dst.insert_item(to, copy, None)?;
out.push(nid);
}
Ok(out)
}
/// The id to reference in `dst` for asset `id` of `src`. When `src` lacks the asset (a missing graphic), the reference
/// gets a fresh id from `dst`, so it stays missing: `src`'s id may already name, or later be allocated to, another
/// image in `dst`.
fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId {
dst.adopt_asset(src, id).unwrap_or_else(|| AssetId(dst.alloc()))
}
/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses.
pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
it.id = ItemId(dst.alloc());
let my_id = it.id;
if let Some(m) = it.media.as_mut()
&& let Some(p) = m.poster
{
m.poster = Some(adopt_or_dangle(dst, src, p));
}
match &mut it.content {
Content::Text(tf) => {
let new_sid = match stories.get(&tf.story) {
Some(n) => *n,
None => {
let n = StoryId(dst.alloc());
let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n));
st.id = n;
let mut assets = HashMap::new();
for a in designcraft_doc::story_asset_refs(&st) {
let na = adopt_or_dangle(dst, src, a);
if na != a {
assets.insert(a, na);
}
}
designcraft_doc::remap_story_assets(&mut st, &assets);
st.frames.clear();
dst.stories.insert(n, Arc::new(st));
stories.insert(tf.story, n);
n
}
};
tf.story = new_sid;
if let Some(st) = dst.story_mut(new_sid) {
st.frames.push(my_id);
}
}
Content::Graphic(g) => {
g.asset = adopt_or_dangle(dst, src, g.asset);
}
Content::Group { items } => {
for c in items.iter_mut() {
renumber(dst, src, Arc::make_mut(c), stories);
}
}
Content::Unassigned => {}
}
}
fn transform_move(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let copy = bool_or(p, "copy", false);
let explicit = p.get("ids").is_some() || p.get("id").is_some();
// Duplicating copies the frame; moving a content selection moves what the frame holds.
let content = p.get("content").and_then(Value::as_bool).unwrap_or(!explicit && !copy && s.doc()?.selection.content);
if content {
return move_content(s, &ids, Vec2::new(dx, dy));
}
let to: Option<SpreadRef> = p.get("toSpread").and_then(|v| serde_json::from_value(v.clone()).ok());
s.edit(|d, sel| {
let ids = if copy {
let src = d.clone();
let mut new = Vec::new();
for id in &ids {
let sr = d.find(*id).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0));
new.extend(duplicate_from(d, &src, &[*id], to.unwrap_or(sr), Vec2::ZERO)?);
}
*sel = Selection::items(new.clone());
new
} else {
ids
};
for id in &ids {
if let Some(target) = to
&& !copy
&& let Some(loc) = d.find(*id)
&& loc.spread != target
&& loc.path.len() == 1
{
let it = d.remove_item_keep_story(*id)?;
d.insert_item(target, it, None)?;
}
// A nested item moves in its container's space.
let v = d.find(*id).map_or(Vec2::new(dx, dy), |l| linear_inverse(d.parent_xf(&l), Vec2::new(dx, dy)));
let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
if it.locked {
continue;
}
it.xf = Affine::translate(v) * it.xf;
}
// An object dragged inside a Flex Layout takes the place it was dropped on.
if !copy {
for id in &ids {
super::flex::drop_in_container(d, *id);
}
}
Ok(json!({"moved": ids.len(), "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
})
}
/// `v` (a spread-space displacement) in the space `m` maps from. Degenerate maps leave it as is.
fn linear_inverse(m: Affine, v: Vec2) -> Vec2 {
let c = m.as_coeffs();
let lin = Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]);
if lin.determinant().abs() < 1e-12 {
return v;
}
let r = lin.inverse() * Point::new(v.x, v.y);
if r.x.is_finite() && r.y.is_finite() { r.to_vec2() } else { v }
}
/// Move what each frame holds — its placed graphic or the items pasted into it — by the
/// spread-space displacement `d`; the frame stays, so the visible part of the content changes.
/// Frames without such content (and groups) move as a whole. Auto-fit stays as it is: the next
/// frame resize refits.
fn move_content(s: &mut Session, ids: &[ItemId], d: Vec2) -> Result<Value> {
s.edit(|doc, _| {
let mut moved = Vec::new();
for id in ids {
let loc = doc.find(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
let parent = doc.parent_xf(&loc);
let it = doc.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
if it.locked {
continue;
}
let inner = linear_inverse(parent * it.xf, d);
let nested = it.has_nested_items();
match &mut it.content {
Content::Graphic(g) => g.xf = Affine::translate(inner) * g.xf,
Content::Group { items } if nested => {
for c in items.iter_mut() {
let c = Arc::make_mut(c);
c.xf = Affine::translate(inner) * c.xf;
}
}
_ => it.xf = Affine::translate(linear_inverse(parent, d)) * it.xf,
}
moved.push(id.0);
}
Ok(json!({"moved": moved.len(), "ids": moved}))
})
}
fn transform_resize(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let from = rect_param(p, "from").ok_or_else(|| bad("transform.resize", "missing from"))?;
let to = rect_param(p, "to").ok_or_else(|| bad("transform.resize", "missing to"))?;
let scale_content = bool_or(p, "content", false);
let distribute = bool_or(p, "distribute", false);
// A flat source (a horizontal or vertical line) has no size to scale on its flat axis: that axis only moves.
let ratio = |new: f64, old: f64| if old.abs() < 1e-9 { 1.0 } else { new / old };
let sx = ratio(to.width(), from.width());
let sy = ratio(to.height(), from.height());
let sx = if sx.abs() < 1e-3 { 1e-3f64.copysign(sx) } else { sx };
let sy = if sy.abs() < 1e-3 { 1e-3f64.copysign(sy) } else { sy };
let m = Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0));
let strokes = s.prefs.scale_strokes;
s.edit(|d, _| {
for id in &ids {
let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
if it.locked {
continue;
}
if distribute {
let c = it.bounds().center();
it.xf = Affine::translate(m * c - c) * it.xf;
} else if scale_content {
scale_object(d, *id, m, (sx * sy).abs().sqrt(), strokes);
} else if it.flex.is_some() {
resize_container(it, m);
} else if matches!(it.content, Content::Group { .. }) {
it.xf = m * it.xf;
} else {
// Resize the frame's path (in inner space); content keeps its size (InDesign's
// default) unless the frame auto-fits it.
let inner = it.xf.inverse() * m * it.xf;
bake(it, inner);
let r = it.inner_bounds();
if let Content::Graphic(g) = &mut it.content
&& let Some(xf) = g.fitted(r, g.auto_fit)
{
g.xf = xf;
}
}
}
Ok(json!({"resized": ids.len()}))
})
}
/// A Flex Layout container takes the new box (its objects keep their size; the layout places them),
/// and a side whose length changes becomes Fixed, as dragging a container's handle does in InDesign.
fn resize_container(it: &mut Item, m: Affine) {
let before = it.inner_bounds();
let inner = it.xf.inverse() * m * it.xf;
bake(it, inner);
let after = it.inner_bounds();
if let Some(f) = it.flex.as_mut() {
if (after.width() - before.width()).abs() > 1e-6 {
f.width = designcraft_doc::FlexSize::Fixed;
}
if (after.height() - before.height()).abs() > 1e-6 {
f.height = designcraft_doc::FlexSize::Fixed;
}
}
}
fn union_bounds(d: &Document, ids: &[ItemId]) -> Rect {
ids.iter().filter_map(|i| d.item(*i)).map(Item::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO)
}
/// Transform an item's geometry in its own space: the path and the gradient vectors (fill and
/// stroke) with it.
pub(crate) fn bake(it: &mut Item, inner: Affine) {
it.path.transform(inner);
if let Some(w) = &mut it.wrap.path {
w.transform(inner);
}
for v in [&mut it.fill.gradient_vector, &mut it.stroke.gradient_vector] {
if let Some([x0, y0, x1, y1]) = *v {
let (a, b) = (inner * Point::new(x0, y0), inner * Point::new(x1, y1));
*v = Some([a.x, a.y, b.x, b.y]);
}
}
}
/// Multiply the stroke weights of an item and everything nested in it.
fn scale_weights(it: &mut Item, k: f64) {
it.stroke.weight *= k;
if let Content::Group { items } = &mut it.content {
for c in items.iter_mut() {
scale_weights(Arc::make_mut(c), k);
}
}
}
/// Scale an item and its content by the spread-space transform `m` (InDesign's "Apply to
/// Content"): paths and placed graphics take the scale into their geometry, so the stroke weight
/// is scaled by `k` (the mean scale factor) only with Scale Strokes on; text frames and groups are
/// transformed as a whole, so with Scale Strokes off their weights are divided by `k` to keep
/// their look.
pub(crate) fn scale_item(it: &mut Item, m: Affine, k: f64, strokes: bool) {
let inner = it.xf.inverse() * m * it.xf;
match &mut it.content {
Content::Unassigned => {
bake(it, inner);
if strokes {
it.stroke.weight *= k;
}
}
Content::Graphic(g) => {
g.xf = inner * g.xf;
bake(it, inner);
if strokes {
it.stroke.weight *= k;
}
}
Content::Text(_) | Content::Group { .. } => {
it.xf = m * it.xf;
if !strokes && k > 1e-9 {
scale_weights(it, 1.0 / k);
}
}
}
}
/// Scale the object `id` and its content by `m` (see [`scale_item`]); a text frame's text takes the
/// scale as [`scale_text_frame`] sets it.
fn scale_object(d: &mut Document, id: ItemId, m: Affine, k: f64, strokes: bool) {
if scale_text_frame(d, id, m, k, strokes) {
return;
}
if let Some(it) = d.item_mut(id) {
scale_item(it, m, k, strokes);
}
}
/// Scale a text frame as InDesign's default "When Scaling: Apply to Content" does: the frame's
/// path takes the scale, so it shows 100%, and the text is set larger or smaller. The point size,
/// leading, baseline shift, text stroke and underline weights follow the vertical scale, the
/// horizontal scale takes the difference between the two axes, and the indents and paragraph
/// spacing follow their own axes. False (the frame is then transformed as a whole) for a story that
/// runs through other frames, vertical text, and a scale that flips or isn't along the frame's own
/// axes.
fn scale_text_frame(d: &mut Document, id: ItemId, m: Affine, k: f64, strokes: bool) -> bool {
let Some(it) = d.item(id) else { return false };
let Content::Text(tf) = &it.content else { return false };
let story = tf.story;
let inner = it.xf.inverse() * m * it.xf;
let [sx, b, c, sy, _, _] = inner.as_coeffs();
if b.abs() > 1e-9 || c.abs() > 1e-9 || !(sx > 1e-6 && sy > 1e-6) || !sx.is_finite() || !sy.is_finite() {
return false;
}
let Some(st) = d.story(story) else { return false };
// Tables, anchored objects and footnotes keep their own sizes, so a frame holding any of them
// scales as a whole.
if st.frames.len() != 1 || st.vertical || !st.tables.is_empty() || !st.objects.is_empty() || !st.notes.is_empty() {
return false;
}
// The resolved attributes of every run, split at paragraph ends (a run's size can come from its
// paragraph's style). Each paragraph keeps its mark.
let mut runs = Vec::new();
let mut paras = Vec::new();
for (i, pr) in st.para_ranges().into_iter().enumerate() {
let Some(pf) = st.paras.get(i) else { break };
let (pp, base) = d.styles.resolve_para(pf);
let end = pr.end.saturating_add(1).min(st.text.len());
for (r, f) in st.runs() {
let (a, z) = (r.start.max(pr.start), r.end.min(end));
if a < z {
runs.push((a..z, d.styles.resolve_char(&base, f)));
}
}
paras.push((pr.start, pp));
}
let h = sx / sy;
let Some(st) = d.story_mut(story) else { return false };
for (r, cp) in runs {
st.format_chars(r, |f| {
let o = &mut f.over;
o.size = Some(cp.size * sy);
if let designcraft_doc::Leading::Points(v) = cp.leading {
o.leading = Some(designcraft_doc::Leading::Points(v * sy));
}
if cp.baseline_shift != 0.0 {
o.baseline_shift = Some(cp.baseline_shift * sy);
}
if (h - 1.0).abs() > 1e-9 {
o.h_scale = Some(cp.h_scale * h);
}
if cp.stroke != "[None]" {
o.stroke_weight = Some(cp.stroke_weight * sy);
}
for (v, w) in [
(&mut o.underline_weight, cp.underline_weight),
(&mut o.underline_offset, cp.underline_offset),
(&mut o.strikethrough_weight, cp.strikethrough_weight),
(&mut o.strikethrough_offset, cp.strikethrough_offset),
] {
if let Some(w) = w {
*v = Some(Some(w * sy));
}
}
});
}
for (at, pp) in paras {
st.format_paras(at..at, |p| {
let a = &mut p.para;
for (v, w, s) in [
(&mut a.left_indent, pp.left_indent, sx),
(&mut a.right_indent, pp.right_indent, sx),
(&mut a.first_line_indent, pp.first_line_indent, sx),
(&mut a.last_line_indent, pp.last_line_indent, sx),
(&mut a.space_before, pp.space_before, sy),
(&mut a.space_after, pp.space_after, sy),
] {
if w != 0.0 {
*v = Some(w * s);
}
}
});
}
let Some(it) = d.item_mut(id) else { return false };
bake(it, inner);
if strokes {
it.stroke.weight *= k;
}
true
}
fn transform_again(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let (list, _, _) = &s.transforms;
if list.is_empty() {
return Err(bad("transform.again", "no transform to repeat"));
}
let steps: Vec<(String, Value)> = if bool_or(p, "sequence", false) { list.clone() } else { list.last().cloned().into_iter().collect() };
let mut groups: Vec<Vec<ItemId>> = if bool_or(p, "individually", false) { ids.iter().map(|i| vec![*i]).collect() } else { vec![ids.clone()] };
// Run the steps directly (no recording, one undo step for the whole repeat).
let saved = s.transforms.clone();
for (cmd, params) in &steps {
let spec = super::find_command(cmd).ok_or_else(|| bad("transform.again", "unknown transform"))?;
for g in &groups {
let q = super::with_param(params, "ids", json!(g.iter().map(|i| i.0).collect::<Vec<_>>()));
(spec.run)(s, &q)?;
}
// Moving a copy selects the copies: later steps (and the next repeat) act on them.
if bool_or(params, "copy", false) {
groups = vec![s.doc()?.selection.items.clone()];
}
}
s.transforms = saved;
Ok(json!({"repeated": steps.len()}))
}
fn clear_transformations(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|d, _| {
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
if matches!(it.content, Content::Group { .. }) && it.shape == Shape::Group {
continue;
}
let c = it.bounds().center();
let inner = it.inner_bounds().center();
it.xf = Affine::translate(c - inner);
}
ok()
})
}
/// Apply `a` to the targets about the `ref` point (0..8, default the centre) of their combined bounds.
fn apply_about_ref(s: &mut Session, p: &Value, a: Affine) -> Result<Value> {
let ids = targets(s, p)?;
let rf = p.get("ref").and_then(Value::as_u64).unwrap_or(4).min(8) as usize;
s.edit(|d, _| {
let c = designcraft_geom::reference_point(union_bounds(d, &ids), rf);
let m = Affine::translate(c.to_vec2()) * a * Affine::translate(-c.to_vec2());
for id in &ids {
if let Some(it) = d.item_mut(*id) {
it.xf = m * it.xf;
}
}
ok()
})
}
fn transform_rotate(s: &mut Session, p: &Value) -> Result<Value> {
let a = f64_or(p, "angle", 0.0);
apply_about_ref(s, p, Affine::rotate(-a.to_radians()))
}
/// The transform an item's panel values are measured from: its own (relative to its container)
/// or, with Transformations are Totals, the whole chain to the pasteboard.
fn measured_xf(d: &designcraft_doc::Document, id: ItemId, totals: bool) -> Option<(Affine, Affine)> {
let loc = d.find(id)?;
let it = d.item_at(&loc)?;
let parent = if totals { d.parent_xf(&loc) } else { Affine::IDENTITY };
Some((parent, parent * it.xf))
}
fn values_json(m: Affine) -> Value {
let v = designcraft_geom::decompose::decompose(m);
let r = |x: f64| (x * 1e4).round() / 1e4;
json!({"scaleX": r(v.scale_x * 100.0), "scaleY": r(v.scale_y * 100.0), "rotation": r(v.rotation), "shear": r(v.shear)})
}
/// Whether `transform.set` / `transform.info` act on the placed graphic in each frame: `content`
/// when given, else whether the selection is content (Direct Selection) when no `ids` or `id` name
/// the targets.
fn content_mode(s: &Session, p: &Value) -> Result<bool> {
if let Some(c) = p.get("content").and_then(Value::as_bool) {
return Ok(c);
}
let explicit = p.get("ids").is_some() || p.get("id").is_some();
Ok(!explicit && s.doc()?.selection.content)
}
fn transform_info(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let totals = s.prefs.transformations_are_totals;
let content = content_mode(s, p)?;
let d = &s.doc()?.doc;
let id = *ids.first().ok_or_else(|| bad("transform.info", "nothing selected"))?;
let (_, m) = measured_xf(d, id, totals).ok_or_else(|| bad("transform.info", "no such object"))?;
let mut out = values_json(m);
if let Some(g) = d.item(id).and_then(Item::graphic) {
let c = values_json(if totals { m * g.xf } else { g.xf });
// With the graphic selected, its values are the ones the Transform fields show and set.
if content {
out = c.clone();
}
out["content"] = c;
}
Ok(out)
}
/// Absolute rotation / shear / scale from the Transform panel, about the reference point.
fn transform_values(s: &mut Session, p: &Value, ids: &[ItemId], rf: usize) -> Result<Value> {
let totals = s.prefs.transformations_are_totals;
let strokes = s.prefs.scale_strokes;
let get = |k: &str| p.get(k).and_then(Value::as_f64);
let (rot, shear, sx, sy) = (get("rotation"), get("shear").map(|v| v.clamp(-85.0, 85.0)), get("scaleX"), get("scaleY"));
if [sx, sy].iter().flatten().any(|v| v.abs() < 0.01) {
return Err(bad("transform.set", "scale too small"));
}
s.edit(|d, _| {
for id in ids {
let Some((parent, cur)) = measured_xf(d, *id, totals) else { continue };
let Some(it) = d.item(*id) else { continue };
// The reference point, on the pasteboard, then in the measuring space.
let full = d.find(*id).map_or(Affine::IDENTITY, |l| d.parent_xf(&l));
let spread_a = designcraft_geom::reference_point((full * it.xf).transform_rect_bbox(it.inner_bounds()), rf);
let a = if totals { spread_a } else { full.inverse() * spread_a };
let v = designcraft_geom::decompose::decompose(cur);
let mut t = v;
if let Some(r) = rot {
t.rotation = r;
}
if let Some(k) = shear {
t.shear = k;
}
let lin = |m: Affine| {
let c = m.as_coeffs();
Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0])
};
let delta = designcraft_geom::decompose::compose(&t) * lin(cur).inverse();
// Δ in measuring space about `a`, brought into the item's container space.
let m = parent.inverse() * Affine::translate(a.to_vec2()) * delta * Affine::translate(-a.to_vec2()) * parent;
let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
it.xf = m * it.xf;
// Scale: relative to what is shown (frames show 100% — their scale lives in the geometry).
if sx.is_some() || sy.is_some() {
let (fx, fy) = (sx.map_or(1.0, |x| x / 100.0 / v.scale_x), sy.map_or(1.0, |y| y / 100.0 / v.scale_y));
let m = Affine::translate(a.to_vec2()) * Affine::scale_non_uniform(fx, fy) * Affine::translate(-a.to_vec2());
let m = parent.inverse() * m * parent;
scale_object(d, *id, m, (fx * fy).abs().sqrt(), strokes);
}
}
ok()
})
}
fn transform_set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
// Direct Selection of a placed graphic (or `content`): the graphic moves inside its frame.
if content_mode(s, p)? {
return transform_content(s, p, &ids);
}
if ["rotation", "shear", "scaleX", "scaleY"].iter().any(|k| p.get(*k).is_some()) {
let rf = p.get("ref").and_then(Value::as_u64).unwrap_or(4) as usize;
if !["x", "y", "width", "height"].iter().any(|k| p.get(*k).is_some()) {
return transform_values(s, p, &ids, rf);
}
transform_values(s, p, &ids, rf)?;
}
let rf = p.get("ref").and_then(Value::as_u64).unwrap_or(0) as usize;
let with_stroke = s.prefs.dimensions_include_stroke;
s.edit(|d, _| {
if !ids.iter().any(|i| d.item(*i).is_some()) {
return ok();
}
// The measured box (with Dimensions Include Stroke Weight, the stroke's outer edge) and
// the geometry it pads.
let geom = union_bounds(d, &ids);
let from = if with_stroke {
ids.iter().filter_map(|i| d.item(*i)).map(Item::visible_bounds).reduce(|a, b| a.union(b)).unwrap_or(geom)
} else {
geom
};
let pad = (geom.x0 - from.x0, geom.y0 - from.y0, from.x1 - geom.x1, from.y1 - geom.y1);
let w = f64_or(p, "width", from.width()).max(0.01);
let h = f64_or(p, "height", from.height()).max(0.01);
let anchor = designcraft_geom::reference_point(from, rf);
// New rect keeping the reference point fixed, then moved so the ref point lands on x/y.
let fx = [0.0, 0.5, 1.0][rf % 3];
let fy = [0.0, 0.5, 1.0][(rf / 3).min(2)];
let mut to = Rect::new(anchor.x - fx * w, anchor.y - fy * h, anchor.x - fx * w + w, anchor.y - fy * h + h);
// x/y are the reference point's position measured from the rulers' zero point.
let origin = ids.first().and_then(|i| d.find(*i)).and_then(|l| d.ruler_origin(l.spread, from.center().x)).unwrap_or(Point::ORIGIN);
if let Some(x) = p.get("x").and_then(Value::as_f64).filter(|v| v.is_finite()) {
to = to + Vec2::new(x + origin.x - anchor.x, 0.0);
}
if let Some(y) = p.get("y").and_then(Value::as_f64).filter(|v| v.is_finite()) {
to = to + Vec2::new(0.0, y + origin.y - anchor.y);
}
let to = Rect::new(to.x0 + pad.0, to.y0 + pad.1, (to.x1 - pad.2).max(to.x0 + pad.0 + 0.01), (to.y1 - pad.3).max(to.y0 + pad.1 + 0.01));
let m = Affine::translate((to.x0, to.y0))
* Affine::scale_non_uniform(to.width() / geom.width().max(1e-9), to.height() / geom.height().max(1e-9))
* Affine::translate((-geom.x0, -geom.y0));
for id in &ids {
if let Some(it) = d.item_mut(*id) {
if it.flex.is_some() {
resize_container(it, m);
} else if matches!(it.content, Content::Group { .. }) {
it.xf = m * it.xf;
} else {
let inner = it.xf.inverse() * m * it.xf;
bake(it, inner);
}
}
}
ok()
})
}
/// `transform.set` on the placed graphics of `ids` (the frames stay): rotation, shear and scale are
/// absolute, measured as `transform.info` reports the content (on the pasteboard with
/// Transformations are Totals, else in the frame); x, y, width and height are the graphic's bounds
/// on the page, about the reference point. Frames without a placed graphic are left alone.
fn transform_content(s: &mut Session, p: &Value, ids: &[ItemId]) -> Result<Value> {
let totals = s.prefs.transformations_are_totals;
let get = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
let (rot, shear, sx, sy) = (get("rotation"), get("shear").map(|v| v.clamp(-85.0, 85.0)), get("scaleX"), get("scaleY"));
let (x, y, w, h) = (get("x"), get("y"), get("width").map(|v| v.max(0.01)), get("height").map(|v| v.max(0.01)));
let bounds = rect_param(p, "bounds").filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()));
if [sx, sy].iter().flatten().any(|v| v.abs() < 0.01) {
return Err(bad("transform.set", "scale too small"));
}
let rf = p.get("ref").and_then(Value::as_u64).unwrap_or(4).min(8) as usize;
{
let d = &s.doc()?.doc;
if !ids.iter().any(|i| d.item(*i).is_some_and(|it| it.graphic().is_some())) {
return Err(bad("transform.set", "no placed graphic to transform"));
}
// Only locked frames: nothing to change, and no undo step.
if !ids.iter().any(|i| d.item(*i).is_some_and(|it| it.graphic().is_some() && !it.locked)) {
return Ok(json!({ "changed": 0 }));
}
}
s.edit(|d, _| {
let mut changed = 0;
for id in ids {
let Some(loc) = d.find(*id) else { continue };
let full = d.parent_xf(&loc);
let Some(it) = d.item(*id) else { continue };
if it.locked {
continue;
}
let Some(g) = it.graphic() else { continue };
let frame = full * it.xf;
let rect = Rect::new(0.0, 0.0, g.size.0.max(1e-9), g.size.1.max(1e-9));
// Rotation, shear and scale in the measuring space (pasteboard or frame).
let q = if totals { frame } else { Affine::IDENTITY };
let mut cur = q * g.xf;
let lin = |m: Affine| {
let c = m.as_coeffs();
Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0])
};
if rot.is_some() || shear.is_some() || sx.is_some() || sy.is_some() {
let a = designcraft_geom::reference_point(cur.transform_rect_bbox(rect), rf).to_vec2();
let v = designcraft_geom::decompose::decompose(cur);
let mut t = v;
if let Some(r) = rot {
t.rotation = r;
}
if let Some(k) = shear {
t.shear = k;
}
if let Some(x) = sx {
t.scale_x = x / 100.0;
}
if let Some(y) = sy {
t.scale_y = y / 100.0;
}
let delta = designcraft_geom::decompose::compose(&t) * lin(cur).inverse();
cur = Affine::translate(a) * delta * Affine::translate(-a) * cur;
}
let mut gx = q.inverse() * cur;
// The graphic's bounds dragged on the spread (its content handles): scale and move it so
// its bounding box becomes `bounds` (normalized; at least 0.01 pt each way).
if let Some(r) = bounds {
let to =
Rect::new(r.x0.min(r.x1), r.y0.min(r.y1), r.x0.max(r.x1).max(r.x0.min(r.x1) + 0.01), r.y0.max(r.y1).max(r.y0.min(r.y1) + 0.01));
let b = (frame * gx).transform_rect_bbox(rect);
let m = Affine::translate((to.x0, to.y0))
* Affine::scale_non_uniform(to.width() / b.width().max(1e-9), to.height() / b.height().max(1e-9))
* Affine::translate((-b.x0, -b.y0));
gx = frame.inverse() * m * frame * gx;
}
// Position and size: the graphic's bounds on the page.
if x.is_some() || y.is_some() || w.is_some() || h.is_some() {
let b = (frame * gx).transform_rect_bbox(rect);
let a = designcraft_geom::reference_point(b, rf);
let (fx, fy) = (w.map_or(1.0, |w| w / b.width().max(1e-9)), h.map_or(1.0, |h| h / b.height().max(1e-9)));
// x/y are measured from the rulers' zero point, as for a frame.
let o = d.ruler_origin(loc.spread, b.center().x).unwrap_or(Point::ORIGIN);
let to = Point::new(x.map_or(a.x, |x| x + o.x), y.map_or(a.y, |y| y + o.y));
let m = Affine::translate(to.to_vec2()) * Affine::scale_non_uniform(fx, fy) * Affine::translate(-a.to_vec2());
gx = frame.inverse() * m * frame * gx;
}
if let Some(Content::Graphic(g)) = d.item_mut(*id).map(|it| &mut it.content) {
g.xf = gx;
changed += 1;
}
}
Ok(json!({ "changed": changed }))
})
}
fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let to = str_param(p, "to").unwrap_or("front").to_string();
s.edit(|d, _| {
if matches!(to.as_str(), "forward" | "backward") {
let selected: HashSet<ItemId> = ids.iter().copied().collect();
let mut positions: Vec<_> = selected
.iter()
.filter_map(|id| {
let loc = d.find(*id)?;
match loc.path.as_slice() {
[i] => Some((loc.spread, *i)),
_ => None,
}
})
.collect();
// Move the leading selected items first, independently of selection/click order.
// Swapping only with unselected siblings leaves the other selected indices valid.
positions.sort_by_key(|(_, i)| *i);
let forward = to == "forward";
if forward {
positions.reverse();
}
for (spread, i) in positions {
let Some(sp) = d.spread_mut(spread) else { continue };
let Some(it) = sp.items.get(i) else { continue };
let layer = it.layer;
let sibling = if forward {
sp.items.iter().enumerate().skip(i.saturating_add(1)).find(|(_, it)| it.layer == layer)
} else {
sp.items.iter().enumerate().take(i).rev().find(|(_, it)| it.layer == layer)
};
if let Some((j, it)) = sibling
&& !selected.contains(&it.id)
{
// Keep every other layer's slots and relative stacking order untouched.
sp.items.swap(i, j);
}
}
return ok();
}
for id in &ids {
let Some(loc) = d.find(*id) else { continue };
if loc.path.len() != 1 {
continue;
}
let Some(sp) = d.spread_mut(loc.spread) else { continue };
let i = loc.path[0];
let n = sp.items.len();
let it = sp.items.remove(i);
let j = match to.as_str() {
"front" => n - 1,
"back" => 0,
_ => i.saturating_sub(1),
};
sp.items.insert(j, it);
}
ok()
})
}
/// An optional non-negative integer parameter; any other value is an error.
fn opt_u64(p: &Value, cmd: &str, key: &str) -> Result<Option<u64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().map(Some).ok_or_else(|| bad(cmd, format!("{key} must be a non-negative integer"))),
}
}
/// The sibling list under the group at `parent` (the spread's top level when empty).
fn siblings_mut<'a>(d: &'a mut Document, r: SpreadRef, parent: &[usize]) -> Option<&'a mut Vec<Arc<Item>>> {
let mut items = &mut d.spread_mut(r)?.items;
for &i in parent {
items = Arc::make_mut(items.get_mut(i)?).children_mut()?;
}
Some(items)
}
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.reorder";
let mut ids = targets(s, p)?;
let mut seen = HashSet::new();
ids.retain(|i| seen.insert(*i));
let Some(&first) = ids.first() else { return Err(bad(C, "no objects")) };
let above = opt_u64(p, C, "above")?.map(ItemId);
let below = opt_u64(p, C, "below")?.map(ItemId);
let index = opt_u64(p, C, "index")?;
let layer = opt_u64(p, C, "layer")?.map(designcraft_doc::LayerId);
if [above.is_some(), below.is_some(), index.is_some()].into_iter().filter(|b| *b).count() > 1 {
return Err(bad(C, "give only one of above, below and index"));
}
let target = above.map(|t| (t, true)).or(below.map(|t| (t, false)));
let d = s.doc()?.doc.clone();
let (spread, parent, new_items) = {
let no_object = |id: ItemId| bad(C, format!("no object {}", id.0));
let loc = d.find(first).ok_or_else(|| no_object(first))?;
let Some((_, parent)) = loc.path.split_last() else { return Err(no_object(first)) };
let parent = parent.to_vec();
let sibling = |id: ItemId| d.find(id).is_some_and(|l| l.spread == loc.spread && l.path.split_last().is_some_and(|(_, pp)| pp == parent));
for id in &ids {
if d.find(*id).is_none() {
return Err(no_object(*id));
}
if !sibling(*id) {
return Err(bad(C, "the objects must be on one spread and in one group"));
}
}
let in_group = !parent.is_empty();
let group_layer = if in_group {
let g = d.item_at(&designcraft_doc::ItemLoc { spread: loc.spread, path: parent.clone() }).ok_or_else(|| no_object(first))?;
if !g.states.is_empty() {
return Err(bad(C, "the states of a multi-state object are reordered in the Object States panel"));
}
Some(g.layer)
} else {
None
};
let own_layer = d.item(first).map(|i| i.layer).ok_or_else(|| no_object(first))?;
let target_layer = match target {
Some((t, _)) => {
if ids.contains(&t) {
return Err(bad(C, "an object can't move next to itself"));
}
if d.find(t).is_none() {
return Err(no_object(t));
}
if !sibling(t) {
return Err(bad(C, "the target must be on the same spread and in the same group"));
}
let tl = d.item(t).map(|i| i.layer).ok_or_else(|| no_object(t))?;
if layer.is_some_and(|l| l != tl) {
return Err(bad(C, "layer differs from the target's layer"));
}
if in_group { own_layer } else { tl }
}
None => layer.unwrap_or(own_layer),
};
if d.layer(target_layer).is_none() {
return Err(bad(C, "no such layer"));
}
if let Some(gl) = group_layer
&& layer.is_some_and(|l| l != gl)
{
return Err(bad(C, "group members stay on their group's layer"));
}
let items = d.spread(loc.spread).map(|sp| sp.items.clone()).ok_or_else(|| no_object(first))?;
let items = parent.iter().try_fold(items, |items, &i| items.get(i).map(|g| g.children().to_vec())).ok_or_else(|| no_object(first))?;
// Moved objects keep their relative stacking order.
let moved: Vec<Arc<Item>> = items.iter().filter(|i| ids.contains(&i.id)).cloned().collect();
let rest: Vec<Arc<Item>> = items.iter().filter(|i| !ids.contains(&i.id)).cloned().collect();
let at = match target {
Some((t, front)) => {
let i = rest.iter().position(|x| x.id == t).ok_or_else(|| no_object(t))?;
if front { i + 1 } else { i }
}
None => {
let k = usize::try_from(index.unwrap_or(0)).map_err(|_| bad(C, "index out of range"))?;
// Positions of the layer's objects (all siblings in a group), topmost first.
let stack: Vec<usize> = rest.iter().enumerate().filter(|(_, x)| in_group || x.layer == target_layer).map(|(i, _)| i).rev().collect();
if k > stack.len() {
return Err(bad(C, format!("index {k} out of range (0..={})", stack.len())));
}
match (stack.get(k), stack.last()) {
(Some(&i), _) => i + 1,
(None, Some(&backmost)) => backmost,
(None, None) => rest.len(),
}
}
};
let (back, front) = rest.split_at(at.min(rest.len()));
let moved = moved.into_iter().map(|mut it| {
if !in_group && it.layer != target_layer {
set_layer_deep(Arc::make_mut(&mut it), target_layer, 0);
}
it
});
let new_items: Vec<Arc<Item>> = back.iter().cloned().chain(moved).chain(front.iter().cloned()).collect();
if new_items.iter().zip(items.iter()).all(|(a, b)| a.id == b.id && a.layer == b.layer) {
return ok();
}
(loc.spread, parent, new_items)
};
s.edit(|d, _| {
let items = siblings_mut(d, spread, &parent).ok_or_else(|| bad(C, "the objects moved"))?;
*items = new_items;
ok()
})
}
/// Sets the layer of `it` and its group members (nesting is bounded).
fn set_layer_deep(it: &mut Item, l: designcraft_doc::LayerId, depth: usize) {
it.layer = l;
if depth < 64
&& let Some(kids) = it.children_mut()
{
for k in kids {
set_layer_deep(Arc::make_mut(k), l, depth + 1);
}
}
}
/// `object.setLayer`: moves the objects, or the top-level groups of group members, to a layer.
fn set_layer(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.setLayer";
let l = designcraft_doc::LayerId(p.get("layer").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing layer"))?);
let ids = targets(s, p)?;
s.edit(|d, _| {
if d.layer(l).is_none() {
return Err(bad(C, "no such layer"));
}
let mut tops: Vec<ItemId> = Vec::new();
for id in &ids {
if let Some(t) = d.top_level_of(*id)
&& !tops.contains(&t)
{
tops.push(t);
}
}
for t in &tops {
if let Some(it) = d.item_mut(*t) {
set_layer_deep(it, l, 0);
}
}
Ok(json!({"changed": tops.len()}))
})
}
/// Where a group of `ids` goes: their spread, the path of the group holding them (empty at top
/// level), and their indices in that list, back to front.
struct GroupPlan {
spread: SpreadRef,
parent: Vec<usize>,
indices: Vec<usize>,
}
/// Objects can be grouped when they share a spread and a parent: all top-level, or all in the
/// same group (which then holds the new group). A multi-state object keeps one state per child,
/// so objects inside one stay as they are.
fn group_plan(d: &Document, ids: &[ItemId]) -> std::result::Result<GroupPlan, String> {
let mut seen = HashSet::new();
let mut locs = Vec::new();
for id in ids {
if seen.insert(*id) {
locs.push(d.find(*id).ok_or_else(|| format!("no object with id {}", id.0))?);
}
}
if locs.len() < 2 {
return Err("select at least two objects".into());
}
let mut plan: Option<GroupPlan> = None;
for loc in locs {
let Some((&i, parent)) = loc.path.split_last() else { return Err("object not found".into()) };
match &mut plan {
None => plan = Some(GroupPlan { spread: loc.spread, parent: parent.to_vec(), indices: vec![i] }),
Some(p) if p.spread == loc.spread && p.parent == parent => p.indices.push(i),
Some(_) => return Err(GROUP_SAME_LEVEL.into()),
}
}
let Some(mut plan) = plan else { return Err("select at least two objects".into()) };
if !plan.parent.is_empty() && d.item_at(&ItemLoc { spread: plan.spread, path: plan.parent.clone() }).is_some_and(|g| !g.states.is_empty()) {
return Err("objects inside a multi-state object can't be grouped".into());
}
plan.indices.sort_unstable();
Ok(plan)
}
const GROUP_SAME_LEVEL: &str = "objects to group must be on the same spread and at the same level (all top-level, or all in the same group)";
/// Menu state of Object › Group: the selection must be groupable.
fn can_group(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or_else(|| "no document open".to_string())?;
group_plan(&st.doc, &st.selection.items).map(|_| ()).map_err(|_| super::CANT_GROUP_SELECTION.into())
}
fn group(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|d, sel| {
let plan = group_plan(d, &ids).map_err(|e| bad("object.group", e))?;
let missing = || bad("object.group", "the objects to group are gone");
let siblings = siblings_mut(d, plan.spread, &plan.parent).ok_or_else(missing)?;
let (Some(&back), Some(&front)) = (plan.indices.first(), plan.indices.last()) else { return Err(missing()) };
let layer = siblings.get(back).ok_or_else(missing)?.layer;
// Keep stacking order: remove front to back, then restore back-to-front order.
let mut kids = Vec::with_capacity(plan.indices.len());
for &i in plan.indices.iter().rev() {
if i >= siblings.len() {
return Err(missing());
}
kids.push(siblings.remove(i));
}
kids.reverse();
let gid = ItemId(d.alloc());
let mut g = Item::new(gid, layer, Shape::Group, designcraft_geom::PathData::default());
let at = (front + 1).saturating_sub(kids.len());
g.content = Content::Group { items: kids };
let siblings = siblings_mut(d, plan.spread, &plan.parent).ok_or_else(missing)?;
siblings.insert(at.min(siblings.len()), Arc::new(g));
*sel = Selection::items(vec![gid]);
Ok(json!({"id": gid.0}))
})
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|d, sel| {
let mut out = Vec::new();
for id in &ids {
let Some(loc) = d.find(*id) else { continue };
let Some(g) = d.item(*id).cloned() else { continue };
// Only groups come apart: a frame holding pasted-into items stays whole, and objects
// that aren't groups stay selected (as in InDesign).
let Content::Group { items } = &g.content else {
out.push(*id);
continue;
};
if !g.is_group() || loc.path.len() != 1 {
out.push(*id);
continue;
}
let Some(sp) = d.spread_mut(loc.spread) else { continue };
sp.items.remove(loc.path[0]);
for (k, c) in items.iter().enumerate() {
let mut c = (**c).clone();
c.xf = g.xf * c.xf;
out.push(c.id);
sp.items.insert(loc.path[0] + k, Arc::new(c));
}
}
*sel = Selection::items(out);
ok()
})
}
fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> {
use designcraft_doc::{ContourType, WrapMode, WrapSide};
let side: Option<WrapSide> = match p.get("side") {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown side"))?),
None => None,
};
// Detected edges, alpha channels and image paths need the image's pixels or paths, which
// composition doesn't read; those types aren't offered.
let contour = match p.get("contour").map(|v| serde_json::from_value::<ContourType>(v.clone())) {
Some(Ok(c @ (ContourType::BoundingBox | ContourType::GraphicFrame | ContourType::SameAsClipping))) => Some(c),
Some(_) => return Err(bad("object.textWrap", "unsupported contour type")),
None => None,
};
let mode: Option<WrapMode> = match p.get("mode") {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown mode"))?),
None if side.is_some() || contour.is_some() => None,
None => Some(WrapMode::BoundingBox),
};
let off = match p.get("offset") {
Some(Value::Number(n)) => Some([n.as_f64().unwrap_or(0.0); 4]),
Some(v) => serde_json::from_value(v.clone()).ok(),
None => None,
};
let invert = p.get("invert").and_then(Value::as_bool);
let content = match p.get("content").and_then(Value::as_bool) {
Some(c) => c,
None => p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.selection.content,
};
set_flag(
s,
p,
move |i| {
let w = match &mut i.content {
Content::Graphic(g) if content => &mut g.wrap,
_ => &mut i.wrap,
};
if let Some(m) = mode {
w.mode = m;
}
if let Some(o) = off {
w.offsets = o;
}
if let Some(v) = invert {
w.invert = v;
}
if let Some(v) = side {
w.side = v;
}
if let Some(c) = contour {
w.contour = c;
}
// A custom boundary has the offsets built in and replaces the contour, so setting
// either one rebuilds the wrap from the frame.
if off.is_some() || contour.is_some() {
w.path = None;
}
},
false,
)
}
fn corner_options(s: &mut Session, p: &Value) -> Result<Value> {
use designcraft_geom::corners::{Corner, CornerOptions, CornerShape};
let opts = match p.get("corners") {
Some(v) => {
let corners: [Corner; 4] =
serde_json::from_value(v.clone()).map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} entries ({e})")))?;
if corners.iter().any(|c| !c.size.is_finite() || c.size < 0.0) {
return Err(bad("object.cornerOptions", "corner sizes must be finite and not negative"));
}
CornerOptions { corners }
}
None => {
let shape = p.get("shape").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(CornerShape::Rounded);
CornerOptions::uniform(shape, f64_or(p, "size", 12.0))
}
};
set_flag(s, p, move |i| i.corners = opts, false)
}
/// `object.matchAttributes`: the selected objects (or `ids`) take `from`'s appearance.
pub(super) fn match_attributes(s: &mut Session, p: &Value) -> Result<Value> {
let from = super::id_param(p, "from").ok_or_else(|| bad("object.matchAttributes", "missing from"))?;
let src = s.doc()?.doc.item(from).cloned().ok_or(designcraft_doc::DocError::NoItem(from))?;
set_flag(
s,
p,
move |i| {
if i.id == src.id {
return;
}
i.fill = src.fill.clone();
i.stroke = src.stroke.clone();
i.corners = src.corners;
i.opacity = src.opacity;
i.blend = src.blend;
i.effects = src.effects.clone();
},
false,
)
}
/// `patch` merged into `base`: objects field by field (recursively), anything else replaced.
fn merge_json(base: &mut Value, patch: &Value) {
match (base, patch) {
(Value::Object(b), Value::Object(p)) => {
for (k, v) in p {
merge_json(b.entry(k.clone()).or_insert(Value::Null), v);
}
}
(b, p) => *b = p.clone(),
}
}
/// Keep effect values in their ranges: opacities 0–1, sizes and distances ≥ 0, percentages 0–100.
fn sanitize_effects(e: &mut designcraft_doc::Effects) -> Result<()> {
const ID: &str = "object.effects";
let len = |v: &mut f64| -> Result<()> {
if !v.is_finite() {
return Err(bad(ID, "values must be finite"));
}
*v = v.clamp(0.0, 10_000.0);
Ok(())
};
let pct = |v: &mut f64| -> Result<()> {
if !v.is_finite() {
return Err(bad(ID, "values must be finite"));
}
*v = v.clamp(0.0, 100.0);
Ok(())
};
let ang = |v: &mut f64| -> Result<()> {
if !v.is_finite() {
return Err(bad(ID, "values must be finite"));
}
*v = (*v + 180.0).rem_euclid(360.0) - 180.0;
Ok(())
};
let op = |v: &mut f32| -> Result<()> {
if !v.is_finite() {
return Err(bad(ID, "values must be finite"));
}
*v = v.clamp(0.0, 1.0);
Ok(())
};
let ds = &mut e.drop_shadow;
op(&mut ds.opacity)?;
ang(&mut ds.angle)?;
len(&mut ds.distance)?;
len(&mut ds.size)?;
pct(&mut ds.spread)?;
let is = &mut e.inner_shadow;
op(&mut is.opacity)?;
ang(&mut is.angle)?;
len(&mut is.distance)?;
len(&mut is.size)?;
pct(&mut is.choke)?;
let og = &mut e.outer_glow;
op(&mut og.opacity)?;
len(&mut og.size)?;
pct(&mut og.spread)?;
let ig = &mut e.inner_glow;
op(&mut ig.opacity)?;
len(&mut ig.size)?;
pct(&mut ig.choke)?;
let bv = &mut e.bevel;
len(&mut bv.size)?;
len(&mut bv.depth)?;
ang(&mut bv.angle)?;
op(&mut bv.highlight_opacity)?;
op(&mut bv.shadow_opacity)?;
let sa = &mut e.satin;
op(&mut sa.opacity)?;
ang(&mut sa.angle)?;
len(&mut sa.distance)?;
len(&mut sa.size)?;
len(&mut e.feather)?;
pct(&mut e.feather_options.choke)?;
for w in &mut e.directional_feather.widths {
len(w)?;
}
let gf = &mut e.gradient_feather;
ang(&mut gf.angle)?;
op(&mut gf.start)?;
op(&mut gf.end)?;
Ok(())
}
fn effects_all(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "object.effects";
let patch = match p.get("effects") {
None | Some(Value::Null) => None,
Some(v @ Value::Object(_)) => Some(v.clone()),
Some(_) => return Err(bad(ID, "effects must be an object")),
};
let opacity = match p.get("opacity") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_f64().filter(|o| o.is_finite()).ok_or_else(|| bad(ID, "opacity must be a number"))?.clamp(0.0, 1.0) as f32),
};
let blend = match p.get("blend") {
None | Some(Value::Null) => None,
Some(v) => {
let raw = v.as_str().ok_or_else(|| bad(ID, "blend must be a string"))?;
Some(BlendMode::parse(raw).ok_or_else(|| bad(ID, format!("unknown blend mode `{raw}`")))?)
}
};
// The document's global light angle, changed from an Angle field with Use Global Light on.
let global_light = match p.get("globalLight") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_f64().filter(|a| a.is_finite()).ok_or_else(|| bad(ID, "globalLight must be a number"))?),
};
let ids = targets(s, p)?;
s.edit(|d, _| {
if let Some(a) = global_light {
d.settings.global_light = (a + 180.0).rem_euclid(360.0) - 180.0;
}
let mut changed = 0;
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
if it.locked {
continue;
}
if let Some(patch) = &patch {
let mut cur = serde_json::to_value(&it.effects).map_err(|e| bad(ID, e.to_string()))?;
merge_json(&mut cur, patch);
let mut fx: designcraft_doc::Effects = serde_json::from_value(cur).map_err(|e| bad(ID, format!("effects: {e}")))?;
sanitize_effects(&mut fx)?;
it.effects = fx;
}
if let Some(o) = opacity {
it.opacity = o;
}
if let Some(b) = blend {
it.blend = b;
}
changed += 1;
}
Ok(json!({"changed": changed}))
})
}
fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|d, sel| {
for id in &ids {
if let Some(it) = d.item_mut(*id) {
f(it);
}
}
if deselect {
*sel = Selection::default();
}
Ok(json!({"changed": ids.len()}))
})
}
/// An object a colour command may change: neither it nor its layer is locked.
fn unlocked(d: &Document, it: &Item) -> bool {
!it.locked && d.layer(it.layer).is_none_or(|l| !l.locked)
}
/// Refuses a path edit on `id` when it, a group it sits in, or its layer is locked.
pub(super) fn path_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> {
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let mut items = d.spread(loc.spread).map(|sp| &sp.items[..]).unwrap_or_default();
for &i in &loc.path {
let Some(it) = items.get(i) else { break };
if !unlocked(d, it) {
return Err(bad(cmd, "the path is locked"));
}
items = it.children();
}
Ok(())
}
fn has_editable_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?;
if st.selection.items.iter().any(|id| st.doc.item(*id).is_some_and(|it| unlocked(&st.doc, it))) {
Ok(())
} else {
Err("the selected objects are locked".into())
}
}
/// Runs `f` on each unlocked target in one edit (one undo step).
fn edit_unlocked(s: &mut Session, p: &Value, f: impl Fn(&mut Item)) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|d, _| {
let mut changed = 0;
for id in &ids {
if d.item(*id).is_some_and(|it| unlocked(d, it))
&& let Some(it) = d.item_mut(*id)
{
f(it);
changed += 1;
}
}
Ok(json!({"changed": changed}))
})
}
/// A stroke that gets a colour needs a weight to show it.
fn visible_weight(st: &mut Stroke) {
if st.swatch != designcraft_color::swatch::NONE && st.weight <= 0.0 {
st.weight = Stroke::default().weight;
}
}
fn swap_fill_stroke(it: &mut Item) {
let fill = std::mem::take(&mut it.fill);
it.fill = Fill { swatch: std::mem::take(&mut it.stroke.swatch), tint: it.stroke.tint, ..fill.clone() };
it.stroke.swatch = fill.swatch;
it.stroke.tint = fill.tint;
visible_weight(&mut it.stroke);
}
fn default_fill_stroke(it: &mut Item) {
it.fill = Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, ..it.fill.clone() };
it.stroke.swatch = designcraft_color::swatch::BLACK.into();
it.stroke.tint = 1.0;
visible_weight(&mut it.stroke);
}
/// Edits the targets' effects. A value that isn't a finite number is refused; the others are
/// clamped to their ranges.
fn set_effects(s: &mut Session, p: &Value, cmd: &'static str, f: impl Fn(&mut Effects)) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|d, _| {
for id in &ids {
if let Some(it) = d.item_mut(*id) {
let mut fx = it.effects.clone();
f(&mut fx);
it.effects = fx.sanitized().map_err(|m| bad(cmd, m))?;
}
}
Ok(json!({"changed": ids.len()}))
})
}
fn all_flag(s: &mut Session, f: impl Fn(&mut Item)) -> Result<Value> {
s.edit(|d, _| {
for i in 0..d.spreads.len() {
let sp = Arc::make_mut(&mut d.spreads[i]);
for it in &mut sp.items {
f(Arc::make_mut(it));
}
}
ok()
})
}
fn content_type(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let t = str_param(p, "type").unwrap_or("unassigned").to_string();
s.edit(|d, _| {
for id in &ids {
let cur = d.item(*id).map(|i| i.content.clone());
match (t.as_str(), cur) {
("text", Some(Content::Unassigned | Content::Graphic(_))) => {
let sid = StoryId(d.alloc());
let mut st = Story::new(sid);
st.direction = d.new_story_direction();
st.frames.push(*id);
d.stories.insert(sid, Arc::new(st));
if let Some(it) = d.item_mut(*id) {
it.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() });
}
}
("graphic" | "unassigned", Some(Content::Text(tf))) => {
let empty = d.story(tf.story).map(|s| s.frames.len() == 1).unwrap_or(true);
if let Some(st) = d.story_mut(tf.story) {
st.frames.retain(|f| f != id);
}
if empty {
d.stories.remove(&tf.story);
}
if let Some(it) = d.item_mut(*id) {
it.content = Content::Unassigned;
}
}
("graphic" | "unassigned", Some(Content::Graphic(_))) if t == "unassigned" => {
if let Some(it) = d.item_mut(*id) {
it.content = Content::Unassigned;
}
}
_ => {}
}
}
ok()
})
}
fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "object.textFrameOptions";
let st = s.doc()?;
let mut ids = targets(s, p)?;
if ids.is_empty()
&& let Some(t) = st.selection.text
{
ids = st.doc.story(t.story).map(|s| s.frames.clone()).unwrap_or_default();
}
let ranged = |key: &str, lo: f64, hi: f64, what: &str| match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => match v.as_f64() {
Some(x) if x.is_finite() && (lo..=hi).contains(&x) => Ok(Some(x)),
_ => Err(bad(ID, format!("{key} must be {what} from {lo} to {hi}, not {v}"))),
},
};
let rule_weight = ranged("columnRuleWeight", 0.0, 1000.0, "a number of points")?;
let rule_tint = ranged("columnRuleTint", 0.0, 1.0, "a tint")?;
let rule_offset = ranged("columnRuleOffset", -1440.0, 1440.0, "a number of points")?;
let rule_top = ranged("columnRuleTopInset", -1440.0, 1440.0, "a number of points")?;
let rule_bottom = ranged("columnRuleBottomInset", -1440.0, 1440.0, "a number of points")?;
let min_height = ranged("minimumHeight", 0.0, crate::autosize::MAX_SIDE, "a number of points")?;
let min_width = ranged("minimumWidth", 0.0, crate::autosize::MAX_SIDE, "a number of points")?;
let column_width = ranged("columnWidth", 0.0, 15552.0, "a number of points")?;
let first_baseline_min = ranged("firstBaselineMin", 0.0, 15552.0, "a number of points")?;
let reference = match p.get("autoSizeReference") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_str()
.and_then(|n| crate::autosize::REFERENCES.iter().position(|r| *r == n))
.ok_or_else(|| bad(ID, format!("autoSizeReference must be one of {}, not {v}", crate::autosize::REFERENCES.join("|"))))?
as u8,
),
};
// `Some(None)` turns the frame's custom baseline grid off.
let custom_grid = match p.get("baselineGrid") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Object(g)) => {
let num = |key: &str, lo: f64, hi: f64| match g.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => match v.as_f64() {
Some(x) if x.is_finite() && (lo..=hi).contains(&x) => Ok(Some(x)),
_ => Err(bad(ID, format!("baselineGrid.{key} must be a number of points from {lo} to {hi}, not {v}"))),
},
};
let start = num("start", -15552.0, 15552.0)?;
let increment = num("increment", 0.0, 15552.0)?;
let relative_to = match g.get("relativeTo") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_str()
.and_then(designcraft_doc::FrameGridRelative::from_name)
.ok_or_else(|| bad(ID, format!("baselineGrid.relativeTo must be page|margin|frame|inset, not {v}")))?,
),
};
Some(Some((start, increment, relative_to)))
}
Some(v) => return Err(bad(ID, format!("baselineGrid must be an object or null, not {v}"))),
};
// A frame without a custom grid starts from the document's.
let doc_grid = {
let g = &st.doc.settings.baseline_grid;
let relative_to = match g.relative_to {
designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage,
designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin,
};
designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to }
};
let rule_color = match p.get("columnRuleColor") {
None | Some(Value::Null) => None,
Some(Value::String(c)) if st.doc.swatch(c).is_some() => Some(c.clone()),
Some(v) => return Err(bad(ID, format!("no swatch {v}"))),
};
let p = p.clone();
s.edit(|d, _| {
for id in &ids {
let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue };
let o = &mut tf.options;
if let Some(v) = p.get("columns").and_then(Value::as_u64) {
o.columns = v.clamp(1, 40) as u32;
}
if let Some(v) = p.get("gutter").and_then(Value::as_f64) {
o.gutter = v.max(0.0);
}
match p.get("inset") {
Some(Value::Number(n)) => o.inset = [n.as_f64().unwrap_or(0.0); 4],
Some(v @ Value::Array(_)) => {
if let Ok(a) = serde_json::from_value::<[Option<f64>; 4]>(v.clone()) {
for (side, v) in o.inset.iter_mut().zip(a) {
if let Some(v) = v {
*side = v;
}
}
}
}
_ => {}
}
for (k, which) in [("verticalJustification", 0), ("firstBaseline", 1), ("autoSize", 2), ("columnsKind", 3)] {
if let Some(v) = p.get(k).cloned() {
let _ = match which {
0 => serde_json::from_value(v).map(|x| o.vertical_justification = x),
1 => serde_json::from_value(v).map(|x| o.first_baseline = x),
2 => serde_json::from_value(v).map(|x| o.auto_size = x),
_ => serde_json::from_value(v).map(|x| o.columns_kind = x),
};
}
}
if let Some(v) = reference {
o.auto_size_ref = v;
}
if let Some(v) = p.get("useMinimumHeight").and_then(Value::as_bool) {
o.auto_size_use_min_height = v;
}
if let Some(v) = min_height {
o.auto_size_min_height = v;
}
if let Some(v) = p.get("useMinimumWidth").and_then(Value::as_bool) {
o.auto_size_use_min_width = v;
}
if let Some(v) = min_width {
o.auto_size_min_width = v;
}
if let Some(v) = p.get("noLineBreaks").and_then(Value::as_bool) {
o.auto_size_no_line_breaks = v;
}
if let Some(v) = p.get("ignoreWrap").and_then(Value::as_bool) {
o.ignore_wrap = v;
}
if let Some(v) = column_width {
o.column_width = v;
}
if let Some(v) = first_baseline_min {
o.first_baseline_min = v;
}
if let Some(v) = p.get("balanceColumns").and_then(Value::as_bool) {
o.balance_columns = v;
}
if let Some(v) = p.get("columnRule").and_then(Value::as_bool) {
o.column_rule = v;
}
if let Some(w) = rule_weight {
o.column_rule_weight = w;
}
if let Some(c) = &rule_color {
o.column_rule_color = c.clone();
}
if let Some(t) = rule_tint {
o.column_rule_tint = t as f32;
}
if let Some(v) = rule_offset {
o.column_rule_offset = v;
}
if let Some(v) = rule_top {
o.column_rule_top_inset = v;
}
if let Some(v) = rule_bottom {
o.column_rule_bottom_inset = v;
}
match custom_grid {
Some(Some((start, increment, relative_to))) => {
let mut g = o.baseline_grid.unwrap_or(doc_grid);
g.start = start.unwrap_or(g.start);
g.increment = increment.unwrap_or(g.increment);
g.relative_to = relative_to.unwrap_or(g.relative_to);
o.baseline_grid = Some(g);
}
Some(None) => o.baseline_grid = None,
None => {}
}
// Fixed Width columns set the frame's width: n columns, the gutters between them and
// the left and right insets.
let fixed_width = o.columns_kind == designcraft_doc::ColumnsKind::FixedWidth && o.column_width > 0.0;
let width = f64::from(o.columns.max(1)) * o.column_width + f64::from(o.columns.max(1) - 1) * o.gutter + o.inset[1] + o.inset[3];
if fixed_width
&& ["columnsKind", "columnWidth", "columns", "gutter", "inset"].iter().any(|k| p.get(*k).is_some())
&& let Some(r) = d.item(*id).map(Item::inner_bounds)
{
let r = super::finite_rect("object.textFrameOptions", Rect::new(r.x0, r.y0, r.x0 + width, r.y1))?;
crate::autosize::resize(d, *id, r);
}
}
// Story direction belongs to the story: every frame of the thread turns.
if let Some(v) = p.get("vertical").and_then(Value::as_bool) {
let stories: Vec<StoryId> = ids.iter().filter_map(|id| d.item(*id).and_then(Item::text_frame).map(|t| t.story)).collect();
super::text::set_story_direction(d, &stories, v);
}
Ok(json!({"changed": ids.len()}))
})
}
/// The gap at `p` on spread `sr`: (vertical gap?, edge a, edge b, ids ending at a, ids starting at b).
pub(crate) fn find_gap(d: &Document, sr: designcraft_doc::SpreadRef, p: Point) -> Option<(bool, f64, f64, Vec<ItemId>, Vec<ItemId>)> {
let sp = d.spread(sr)?;
let page = sp.page_at_x(p.x).and_then(|i| sp.pages.get(i)).map(|pg| pg.bounds())?;
let items: Vec<(ItemId, Rect)> = sp
.items
.iter()
.filter(|it| !it.hidden && !it.locked && d.layer(it.layer).is_none_or(|l| !l.locked && l.visible))
.map(|it| (it.id, it.bounds()))
.collect();
if items.iter().any(|(_, b)| b.contains(p)) {
return None;
}
// Between left and right neighbours (a vertical gap), or above and below (horizontal).
let side = |vertical: bool| {
let (pos, lo, hi) = if vertical { (p.x, page.x0, page.x1) } else { (p.y, page.y0, page.y1) };
let across = |b: &Rect| if vertical { b.y0 <= p.y && p.y <= b.y1 } else { b.x0 <= p.x && p.x <= b.x1 };
let end = |b: &Rect| if vertical { b.x1 } else { b.y1 };
let start = |b: &Rect| if vertical { b.x0 } else { b.y0 };
let a = items.iter().filter(|(_, b)| across(b) && end(b) <= pos).map(|(_, b)| end(b)).fold(lo, f64::max);
let z = items.iter().filter(|(_, b)| across(b) && start(b) >= pos).map(|(_, b)| start(b)).fold(hi, f64::min);
let before: Vec<ItemId> = items.iter().filter(|(_, b)| across(b) && (end(b) - a).abs() < 0.5).map(|(i, _)| *i).collect();
let after: Vec<ItemId> = items.iter().filter(|(_, b)| across(b) && (start(b) - z).abs() < 0.5).map(|(i, _)| *i).collect();
(!before.is_empty() || !after.is_empty()).then_some((vertical, a, z, before, after))
};
match (side(true), side(false)) {
(Some(v), Some(h)) => Some(if v.2 - v.1 <= h.2 - h.1 { v } else { h }),
(v, h) => v.or(h),
}
}
fn gap_move(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "gap.move";
let at = p
.get("at")
.and_then(Value::as_array)
.and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)))
.ok_or_else(|| bad(ID, "missing at"))?;
let sr = spread_param(p, ID)?;
let (vertical, a, z, before, after) = find_gap(&s.doc()?.doc, sr, at).ok_or_else(|| bad(ID, "no gap there"))?;
// `delta` along the gap's axis, or a drag (dx, dy) of which the axis part counts.
let delta =
p.get("delta").and_then(Value::as_f64).unwrap_or_else(|| p.get(if vertical { "dx" } else { "dy" }).and_then(Value::as_f64).unwrap_or(0.0));
let _ = (a, z);
s.edit(|d, _| {
let mut n = 0;
for (ids, is_before) in [(&before, true), (&after, false)] {
for id in ids {
let Some(it) = d.item_mut(*id) else { continue };
let b = it.bounds();
let mut to = b;
match (vertical, is_before) {
(true, true) => to.x1 = (b.x1 + delta).max(b.x0 + 1.0),
(true, false) => to.x0 = (b.x0 + delta).min(b.x1 - 1.0),
(false, true) => to.y1 = (b.y1 + delta).max(b.y0 + 1.0),
(false, false) => to.y0 = (b.y0 + delta).min(b.y1 - 1.0),
}
let m = Affine::translate((to.x0, to.y0))
* Affine::scale_non_uniform(to.width() / b.width().max(1e-9), to.height() / b.height().max(1e-9))
* Affine::translate((-b.x0, -b.y0));
if matches!(it.content, Content::Group { .. }) {
it.xf = m * it.xf;
} else {
let inner = it.xf.inverse() * m * it.xf;
bake(it, inner);
}
n += 1;
}
}
Ok(json!({"axis": if vertical { "x" } else { "y" }, "changed": n}))
})
}
/// Clipping Path: trace the graphic (its alpha channel, or edges against a white background) and
/// give the frame that outline.
fn clipping_path(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "object.clippingPath";
let ids = targets(s, p)?;
let edges = match str_param(p, "type").unwrap_or("alpha") {
"alpha" => false,
"edges" => true,
t => return Err(bad(ID, format!("unknown type `{t}` (alpha, edges)"))),
};
let threshold = p.get("threshold").and_then(Value::as_f64).unwrap_or(25.0).clamp(0.0, 255.0);
let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(2.0).max(0.1);
// Trace each graphic (outside the edit: decoding is the slow part).
let mut paths: Vec<(ItemId, designcraft_geom::PathData)> = Vec::new();
{
let d = &s.doc()?.doc;
for id in &ids {
let Some(it) = d.item(*id) else { continue };
let Some(g) = it.graphic() else { continue };
let Some(asset) = d.assets.get(&g.asset) else { continue };
let Some(px) = designcraft_render::decode_pixmap_page(&asset.data, asset.page) else { continue };
// A placed PDF cropped to a box: trace that box, which graphic space spans.
let px = match designcraft_render::shown_box(asset) {
Some(frac) => designcraft_render::crop_pixmap(&px, frac).unwrap_or(px),
None => px,
};
let (pw, ph) = (px.width() as usize, px.height() as usize);
// A grid of at most 400 cells on the long side.
let step = (pw.max(ph) as f64 / 400.0).max(1.0);
let (w, h) = (((pw as f64) / step).ceil() as usize, ((ph as f64) / step).ceil() as usize);
let mask: Vec<bool> = (0..w * h)
.map(|i| {
let (x, y) = (((i % w) as f64 * step) as u16, ((i / w) as f64 * step) as u16);
let c = px.sample(x.min(pw as u16 - 1), y.min(ph as u16 - 1));
if edges {
// Un-premultiply over white: anything darker than the threshold is inside.
let a = c.a as f64 / 255.0;
let lum = (0.299 * c.r as f64 + 0.587 * c.g as f64 + 0.114 * c.b as f64) + 255.0 * (1.0 - a);
lum < 255.0 - threshold
} else {
c.a as f64 > threshold
}
})
.collect();
// The tolerance is in image pixels; the grid cells are `step` pixels.
let rings = designcraft_geom::trace::trace(&mask, w, h, tol / step, 4.0);
if rings.is_empty() {
return Err(bad(ID, "nothing to trace (the image is empty at this threshold)"));
}
let (sx, sy) = (g.size.0 / w as f64, g.size.1 / h as f64);
let subpaths = rings
.into_iter()
.map(|r| {
let pts: Vec<Point> = r.into_iter().map(|q| g.xf * Point::new(q.x * sx, q.y * sy)).collect();
designcraft_geom::path::SubPath::polyline(&pts, true)
})
.collect();
paths.push((*id, designcraft_geom::PathData::new(subpaths)));
}
}
if paths.is_empty() {
return Err(bad(ID, "select a frame with a placed graphic"));
}
s.edit(|d, _| {
let n = paths.len();
for (id, path) in paths {
if let Some(it) = d.item_mut(id) {
it.path = path;
it.shape = Shape::Path;
it.corners = Default::default();
if let Content::Graphic(g) = &mut it.content {
g.auto_fit = designcraft_doc::Fitting::None;
}
}
}
Ok(json!({"changed": n}))
})
}
/// Clipping Path Options: report a graphic's clipping path, remove it, or change its options.
fn clipping_path_options(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "object.clippingPathOptions";
let ids = targets(s, p)?;
let remove = match str_param(p, "type") {
None => false,
Some("none") => true,
Some(t) => return Err(bad(ID, format!("type `{t}` can't be set here (only `none`); object.clippingPath traces an outline"))),
};
let flag = |key: &str| p.get(key).map(|v| v.as_bool().ok_or_else(|| bad(ID, format!("{key} must be true or false")))).transpose();
let (invert, inside) = (flag("invert")?, flag("includeInsideEdges")?);
let graphics: Vec<ItemId> = {
let d = &s.doc()?.doc;
ids.into_iter().filter(|id| d.item(*id).and_then(Item::graphic).is_some()).collect()
};
if graphics.is_empty() {
return Err(bad(ID, "select a frame with a placed graphic"));
}
if !remove && invert.is_none() && inside.is_none() {
let d = &s.doc()?.doc;
let items: Vec<Value> = graphics
.iter()
.filter_map(|id| d.item(*id).and_then(Item::graphic).map(|g| (id, g)))
.map(|(id, g)| match &g.clip {
None => json!({"id": id.0, "type": "none"}),
Some(c) => json!({
"id": id.0,
"type": serde_json::to_value(c.kind).ok(),
"anchors": c.path.anchor_count(),
"invert": c.invert,
"includeInsideEdges": c.include_inside_edges,
"threshold": c.threshold,
"tolerance": c.tolerance,
"inset": c.inset,
"sourceName": c.source_name,
}),
})
.collect();
return Ok(json!({"items": items}));
}
s.edit(|d, _| {
let mut n = 0;
for id in &graphics {
let Some(Content::Graphic(g)) = d.item_mut(*id).map(|it| &mut it.content) else { continue };
if remove {
n += usize::from(g.clip.take().is_some());
} else if let Some(c) = &mut g.clip {
let c = Arc::make_mut(c);
c.invert = invert.unwrap_or(c.invert);
c.include_inside_edges = inside.unwrap_or(c.include_inside_edges);
n += 1;
}
}
Ok(json!({"changed": n}))
})
}
fn fitting_mode(s: &str) -> Option<designcraft_doc::Fitting> {
use designcraft_doc::Fitting as F;
Some(match s {
"none" => F::None,
"fillProportionally" => F::FillProportionally,
"fitProportionally" => F::FitProportionally,
"fitContentToFrame" => F::FitContentToFrame,
"centerContent" => F::CenterContent,
_ => return None,
})
}
/// Frame Fitting Options: auto-fit, fitting, align from, crop amounts.
fn fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "object.fittingOptions";
let ids = targets(s, p)?;
let fitting = match str_param(p, "fitting") {
Some(f) => Some(fitting_mode(f).ok_or_else(|| bad(ID, format!("unknown fitting `{f}`")))?),
None => None,
};
let auto = p.get("autoFit").and_then(Value::as_bool);
let align = p.get("align").and_then(Value::as_u64).map(|v| v.min(8) as u8);
let crop = match p.get("crop") {
Some(Value::Array(a)) if a.len() == 4 => Some([0, 1, 2, 3].map(|i| a[i].as_f64().unwrap_or(0.0))),
Some(Value::Number(n)) => Some([n.as_f64().unwrap_or(0.0); 4]),
Some(v) => return Err(bad(ID, format!("bad `crop`: {v} (n or [t, l, b, r])"))),
None => None,
};
s.edit(|d, _| {
let mut n = 0;
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
let r = it.inner_bounds();
let Content::Graphic(g) = &mut it.content else { continue };
if let Some(a) = align {
g.fit_align = a;
}
if let Some(c) = crop {
g.crop = c;
}
let mode = fitting.unwrap_or(g.auto_fit);
if let Some(a) = auto {
g.auto_fit = if a {
if mode == designcraft_doc::Fitting::None { designcraft_doc::Fitting::FillProportionally } else { mode }
} else {
designcraft_doc::Fitting::None
};
} else if fitting.is_some() && g.auto_fit != designcraft_doc::Fitting::None {
g.auto_fit = mode;
}
if let Some(xf) = g.fitted(r, mode) {
g.xf = xf;
}
n += 1;
}
Ok(json!({"changed": n}))
})
}
fn fit(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
let ids: Vec<_> =
targets(s, p)?.into_iter().filter(|id| doc.item(*id).is_some_and(|it| !it.locked && doc.layer(it.layer).is_none_or(|l| !l.locked))).collect();
let mode = str_param(p, "mode").unwrap_or("fitProportionally").to_string();
let (height, width) = match str_param(p, "axis").unwrap_or("both") {
"height" => (true, false),
"width" => (false, true),
"both" => (true, true),
other => return Err(bad("object.fit", format!("unknown axis `{other}`"))),
};
// Text frames: Fit Frame to Content fits the frame to its composed text.
let mut text_fits = Vec::new();
if mode == "fitFrameToContent" {
let d = s.doc()?.doc.clone();
for id in &ids {
if let Some(r) = fitted_text_frame(&d, *id, height, width)? {
text_fits.push((*id, r));
}
}
}
s.edit(|d, _| {
for (id, r) in &text_fits {
if let Some(it) = d.item_mut(*id) {
it.path = shapes::rectangle(*r);
}
}
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
let r = it.inner_bounds();
let Content::Graphic(g) = &mut it.content else { continue };
let (nw, nh) = g.size;
let cur = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, nw, nh));
if mode == "fitFrameToContent" {
it.path = shapes::rectangle(Rect::new(
if width { cur.x0 } else { r.x0 },
if height { cur.y0 } else { r.y0 },
if width { cur.x1 } else { r.x1 },
if height { cur.y1 } else { r.y1 },
));
continue;
}
let m = fitting_mode(&mode)
.filter(|m| *m != designcraft_doc::Fitting::None)
.ok_or_else(|| bad("object.fit", format!("unknown mode `{mode}`")))?;
if let Some(xf) = g.fitted(r, m) {
g.xf = xf;
}
}
ok()
})
}
/// The inner rectangle that fits text frame `id` to its text (Fit Frame to Content): the smallest
/// fitting height and the right side at the longest line's end (`width`, one column only),
/// including insets; the top-left stays. Overset text grows the frame first. None for
/// frames that aren't horizontal text frames, have no text, or are threaded on to another frame,
/// and for auto-size frames, which already fit their text around their reference point.
fn fitted_text_frame(d: &Document, id: ItemId, height: bool, width: bool) -> Result<Option<Rect>> {
let Some(it) = d.item(id) else { return Ok(None) };
let Some(tf) = it.text_frame() else { return Ok(None) };
if tf.options.auto_size != designcraft_doc::AutoSize::Off {
return Ok(None);
}
if d.frame_vertical(it) || tf.options.path.is_some() {
return Ok(None);
}
let Some(story) = d.story(tf.story) else { return Ok(None) };
// The last frame of its thread only: an earlier frame's text runs on.
if story.frames.last() != Some(&id) {
return Ok(None);
}
let r = it.inner_bounds();
if !(r.width().is_finite() && r.height().is_finite() && r.width() > 0.0 && r.height() > 0.0) {
return Err(bad("object.fit", "text frame bounds must be finite and nonempty"));
}
// Keep trial layouts out of the session cache. Search with the actual columns and frame
// options: measuring a very tall frame collapses columns and includes vertical alignment's
// extra space. A fitting trial must compose all the text, regardless of its justification.
let cache = designcraft_compose::Cache::new();
let mut trial = d.clone();
let mut compose = |rect| {
if let Some(it) = trial.item_mut(id) {
it.path = shapes::rectangle(rect);
}
cache.get(&trial, tf.story, None)
};
let initial = compose(r);
if initial.frame(id).is_none_or(|ft| ft.lines.is_empty()) && !initial.is_overset() {
return Ok(None);
}
let mut out = r;
// First fit the height at the existing width, then measure the complete text's width.
// Recheck height after fitting width, since a small width change can change a line break.
if height {
out = fit_text_height(out, id, tf.options.inset[2], &mut compose)?;
}
if width && tf.options.columns <= 1 {
let cs = compose(out);
if let Some(ft) = cs.frame(id) {
let right = ft.lines.iter().map(|l| l.end_x).fold(f64::NEG_INFINITY, f64::max);
if right.is_finite() {
out.x1 = (right + tf.options.inset[3] + 0.001).max(r.x0 + 1.0);
}
}
if height {
out = fit_text_height(out, id, tf.options.inset[2], &mut compose)?;
}
}
Ok(Some(out))
}
fn fit_text_height(
mut rect: Rect,
id: ItemId,
inset_bottom: f64,
compose: &mut impl FnMut(Rect) -> Arc<designcraft_compose::ComposedStory>,
) -> Result<Rect> {
let mut low = 1.0;
let limit = 20_000.0;
let mut high = rect.height().clamp(1.0, limit);
rect.y1 = rect.y0 + high;
while compose(rect).is_overset() {
if high >= limit {
return Err(bad("object.fit", "text does not fit within the 20,000 pt fitting limit"));
}
low = high;
high = (high * 2.0).min(limit);
rect.y1 = rect.y0 + high;
}
// Bounded search, always retaining a verified fitting upper bound.
for _ in 0..32 {
if high - low < 0.001 {
break;
}
let mid = low + (high - low) / 2.0;
rect.y1 = rect.y0 + mid;
if compose(rect).is_overset() {
low = mid;
} else {
high = mid;
}
}
rect.y1 = rect.y0 + high;
// A line is set when its baseline is within 0.01 pt of the bottom, so the search stops just
// short of the lowest baseline. The bottom goes onto it (plus the inset): the frame then fits
// its text exactly and still does when it is scaled up.
let lowest = compose(rect).frame(id).and_then(|ft| ft.lines.iter().map(|l| l.baseline).reduce(f64::max));
if let Some(b) = lowest.map(|b| b + inset_bottom).filter(|b| b.is_finite() && *b > rect.y1 && *b - rect.y1 <= 0.02) {
rect.y1 = b;
}
Ok(rect)
}
pub(crate) trait RemoveKeep {
fn remove_item_keep_story(&mut self, id: ItemId) -> Result<Item>;
}
/// Detach an item without touching story threads (moving between spreads, grouping).
impl RemoveKeep for Document {
fn remove_item_keep_story(&mut self, id: ItemId) -> Result<Item> {
let loc = self.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
if loc.path.len() != 1 {
return Err(bad("object", "nested items can't be detached"));
}
let sp = self.spread_mut(loc.spread).ok_or(designcraft_doc::DocError::NoItem(id))?;
Ok(Arc::unwrap_or_clone(sp.items.remove(loc.path[0])))
}
}
/// Bounds of each target item (spread space) and the alignment reference rect.
fn align_reference(d: &Document, ids: &[ItemId], to: &str, key: Option<ItemId>) -> Option<Rect> {
let bounds: Vec<Rect> = ids.iter().filter_map(|i| d.item(*i).map(Item::bounds)).collect();
let union = bounds.iter().copied().reduce(|a, b| a.union(b))?;
let first = ids.first().and_then(|i| d.find(*i))?;
let sp = d.spread(first.spread)?;
let page = sp.page_at_x(union.center().x).and_then(|pi| sp.pages.get(pi));
Some(match to {
"keyObject" => key.and_then(|k| d.item(k)).map(Item::bounds).unwrap_or(union),
"margins" => page.map(|p| p.margin_rect()).unwrap_or(union),
"page" => page.map(|p| p.bounds()).unwrap_or(union),
"spread" => sp.bounds(),
_ => union,
})
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let edge = str_param(p, "edge").unwrap_or("left").to_string();
// A key object counts only while it is one of several items aligned. With one, InDesign's
// Align To switches to Key Object, so it is also the default target.
let key = s.doc()?.selection.key.filter(|k| ids.len() > 1 && ids.contains(k));
let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| {
if key.is_some() {
"keyObject".into()
} else if ids.len() > 1 {
"selection".into()
} else {
"page".into()
}
});
s.edit(|d, _| {
let r = align_reference(d, &ids, &to, key).ok_or_else(|| bad("object.align", "nothing to align"))?;
for id in &ids {
if Some(*id) == key && to == "keyObject" {
continue;
}
let Some(it) = d.item_mut(*id) else { continue };
if it.locked {
continue;
}
let b = it.bounds();
let (dx, dy) = match edge.as_str() {
"left" => (r.x0 - b.x0, 0.0),
"hcenter" => (r.center().x - b.center().x, 0.0),
"right" => (r.x1 - b.x1, 0.0),
"top" => (0.0, r.y0 - b.y0),
"vcenter" => (0.0, r.center().y - b.center().y),
"bottom" => (0.0, r.y1 - b.y1),
other => return Err(bad("object.align", format!("unknown edge `{other}`"))),
};
it.xf = Affine::translate((dx, dy)) * it.xf;
}
ok()
})
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
if ids.len() < 3 && p.get("spacing").is_none() {
return Err(bad("object.distribute", "select at least three objects"));
}
let horizontal = str_param(p, "axis") != Some("vertical");
let by_spacing = str_param(p, "by") == Some("spacing") || p.get("spacing").is_some();
let fixed = p.get("spacing").and_then(Value::as_f64);
s.edit(|d, _| {
let mut items: Vec<(ItemId, Rect)> = ids.iter().filter_map(|i| d.item(*i).map(|it| (*i, it.bounds()))).collect();
let key = |r: &Rect| if horizontal { r.x0 } else { r.y0 };
items.sort_by(|a, b| key(&a.1).total_cmp(&key(&b.1)));
let n = items.len();
if n < 2 {
return ok();
}
let (first, last) = (items[0].1, items[n - 1].1);
let lo = |r: &Rect| if horizontal { r.x0 } else { r.y0 };
let hi = |r: &Rect| if horizontal { r.x1 } else { r.y1 };
let size = |r: &Rect| hi(r) - lo(r);
let mut targets: Vec<f64> = Vec::with_capacity(n);
if by_spacing {
let total: f64 = items.iter().map(|(_, r)| size(r)).sum();
let gap = fixed.unwrap_or_else(|| (hi(&last) - lo(&first) - total) / (n - 1) as f64);
let mut pos = lo(&first);
for (_, r) in &items {
targets.push(pos);
pos += size(r) + gap;
}
} else {
let c0 = (lo(&first) + hi(&first)) / 2.0;
let c1 = (lo(&last) + hi(&last)) / 2.0;
for (k, (_, r)) in items.iter().enumerate() {
targets.push(c0 + (c1 - c0) * k as f64 / (n - 1) as f64 - size(r) / 2.0);
}
}
for ((id, r), t) in items.iter().zip(targets) {
let delta = t - lo(r);
if let Some(it) = d.item_mut(*id) {
// Keep locked items in the spacing calculation, as align does for its reference.
if it.locked {
continue;
}
let v = if horizontal { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) };
it.xf = Affine::translate(v) * it.xf;
}
}
ok()
})
}
fn anchor_from(v: &Value) -> Option<designcraft_geom::Anchor> {
let pt = |k: &str| -> Option<Point> {
let a = v.get(k)?.as_array()?;
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
};
let p = pt("p")?;
let h_in = pt("in").unwrap_or(p);
let h_out = pt("out").unwrap_or(p);
let smooth = (h_in - p).hypot() > 1e-9 || (h_out - p).hypot() > 1e-9;
Some(designcraft_geom::Anchor {
p,
h_in,
h_out,
kind: if smooth { designcraft_geom::AnchorKind::Smooth } else { designcraft_geom::AnchorKind::Corner },
})
}
fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
let anchors: Vec<designcraft_geom::Anchor> =
p.get("anchors").and_then(Value::as_array).map(|a| a.iter().filter_map(anchor_from).collect()).unwrap_or_default();
if anchors.len() < 2 {
return Err(bad("path.create", "a path needs at least two anchors"));
}
let closed = bool_or(p, "closed", false);
let sr = spread_param(p, "path.create")?;
let lid = s.doc()?.active_layer;
s.edit(|d, sel| {
let id = ItemId(d.alloc());
let shape = if anchors.len() == 2 && !closed { Shape::GraphicLine } else { Shape::Path };
let mut it = Item::new(id, lid, shape, designcraft_geom::PathData::single(designcraft_geom::SubPath::new(anchors, closed)));
it.stroke = Stroke::default();
new_object_colors(d, &mut it);
d.insert_item(sr, it, None)?;
*sel = Selection::items(vec![id]);
Ok(json!({"id": id.0}))
})
}
fn path_append(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?;
let subpath = p.get("subpath").and_then(Value::as_u64);
let at_start = match p.get("at") {
None => false,
Some(v) if v.as_str() == Some("end") => false,
Some(v) if v.as_str() == Some("start") => true,
Some(_) => return Err(bad("path.appendAnchor", "`at` must be `start` or `end`")),
};
s.edit(|d, _| {
path_editable(d, id, "path.appendAnchor")?;
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let sp = match subpath {
Some(i) => {
usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?
}
None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?,
};
if sp.closed {
return Err(bad("path.appendAnchor", "the subpath is closed"));
}
if at_start {
sp.anchors.insert(0, a.transform(inv));
} else {
sp.anchors.push(a.transform(inv));
}
if it.shape == Shape::GraphicLine {
it.shape = Shape::Path;
}
ok()
})
}
fn path_set_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "path.setAnchor";
let id = super::id_param(p, "id").ok_or_else(|| bad(ID, "missing id"))?;
let pt = |k: &str| -> Option<Point> {
let a = p.get(k)?.as_array()?;
let q = Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?);
(q.x.is_finite() && q.y.is_finite()).then_some(q)
};
let (new_p, new_in, new_out) = (pt("p"), pt("in"), pt("out"));
if new_p.is_none() && new_in.is_none() && new_out.is_none() {
return Err(bad(ID, "give p, in or out"));
}
let subpath = p.get("subpath").and_then(Value::as_u64);
let which = p.get("anchor").cloned().unwrap_or(Value::Null);
s.edit(|d, _| {
path_editable(d, id, ID)?;
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let sp = match subpath {
Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)),
None => it.path.subpaths.last_mut(),
}
.ok_or_else(|| bad(ID, "no such subpath"))?;
let n = sp.anchors.len();
let ai = match &which {
Value::String(w) if w == "first" => 0,
Value::String(w) if w == "last" => n.saturating_sub(1),
v => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "anchor: an index, \"first\" or \"last\""))?,
};
let a = sp.anchors.get_mut(ai).ok_or_else(|| bad(ID, "no such anchor"))?;
if let Some(q) = new_p {
let v = inv * q - a.p;
a.p += v;
a.h_in += v;
a.h_out += v;
}
if let Some(q) = new_in {
a.h_in = inv * q;
}
if let Some(q) = new_out {
a.h_out = inv * q;
}
if new_in.is_some() && new_out.is_some() {
let (i, o) = (a.h_in - a.p, a.h_out - a.p);
let collinear = (i.x * o.y - i.y * o.x).abs() <= 1e-6 * i.hypot().max(1.0) * o.hypot().max(1.0) && i.dot(o) <= 0.0;
a.kind = if collinear { designcraft_geom::AnchorKind::Smooth } else { designcraft_geom::AnchorKind::Corner };
}
ok()
})
}
fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX));
if let (Some(i), Some(id)) = (only, ids.first())
&& s.doc()?.doc.item(*id).is_none_or(|it| it.path.subpaths.get(i).is_none())
{
return Err(bad("path.reverse", "no such subpath"));
}
let ids: Vec<ItemId> = if only.is_some() { ids.into_iter().take(1).collect() } else { ids };
let doc = &s.doc()?.doc;
let editable: Vec<ItemId> = ids.iter().copied().filter(|id| path_editable(doc, *id, "path.reverse").is_ok()).collect();
if editable.is_empty()
&& let Some(id) = ids.first()
{
path_editable(doc, *id, "path.reverse")?;
}
s.edit(|d, _| {
let mut reversed = 0;
for id in &editable {
let Some(it) = d.item_mut(*id) else { continue };
if it.is_group() {
continue;
}
for (i, sp) in it.path.subpaths.iter_mut().enumerate() {
if only.is_some_and(|o| o != i) {
continue;
}
sp.anchors.reverse();
for a in &mut sp.anchors {
std::mem::swap(&mut a.h_in, &mut a.h_out);
}
}
reversed += 1;
}
Ok(json!({"reversed": reversed}))
})
}
fn path_close(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX));
s.edit(|d, _| {
path_editable(d, id, "path.close")?;
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
if only.is_some_and(|i| i >= it.path.subpaths.len()) {
return Err(bad("path.close", "no such subpath"));
}
for (i, sp) in it.path.subpaths.iter_mut().enumerate() {
if only.is_none_or(|o| o == i) {
sp.closed = true;
}
}
if it.shape == Shape::GraphicLine {
it.shape = Shape::Path;
}
ok()
})
}
fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?;
let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let handle = str_param(p, "handle").map(str::to_string);
s.edit(|d, _| {
path_editable(d, id, "path.moveAnchors")?;
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();
// Spread-space delta → inner-space delta (linear part only).
let inv = m.inverse();
let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO);
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
for (si, ai) in &which {
let Some(a) = it.path.anchor_mut(*si, *ai) else { continue };
match handle.as_deref() {
Some("in") => {
a.h_in += dv;
if a.kind == designcraft_geom::AnchorKind::Smooth {
a.h_out = a.p - (a.h_in - a.p);
}
}
Some("out") => {
a.h_out += dv;
if a.kind == designcraft_geom::AnchorKind::Smooth {
a.h_in = a.p - (a.h_out - a.p);
}
}
_ => a.translate(dv),
}
}
if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon {
it.shape = Shape::Path;
}
ok()
})
}
const MAX_STEP_REPEAT_COPIES: u64 = 1000;
const MAX_STEP_REPEAT_WORK: usize = 1000;
const MAX_STEP_REPEAT_OFFSET: f64 = 1_000_000_000.0;
fn step_repeat_count(p: &Value, key: &str, default: u64) -> Result<u64> {
let Some(value) = p.get(key) else { return Ok(default) };
let count = value.as_u64().ok_or_else(|| bad("edit.stepAndRepeat", format!("`{key}` must be an integer")))?;
if !(1..=MAX_STEP_REPEAT_COPIES).contains(&count) {
return Err(bad("edit.stepAndRepeat", format!("`{key}` must be between 1 and {MAX_STEP_REPEAT_COPIES}")));
}
Ok(count)
}
fn step_repeat_delta(p: &Value, key: &str, default: f64) -> Result<f64> {
let Some(value) = p.get(key) else { return Ok(default) };
let delta = value.as_f64().ok_or_else(|| bad("edit.stepAndRepeat", format!("`{key}` must be a finite number")))?;
if !delta.is_finite() {
return Err(bad("edit.stepAndRepeat", format!("`{key}` must be a finite number")));
}
Ok(delta)
}
fn checked_step_offset(dx: f64, dy: f64, x: u64, y: u64) -> Result<Vec2> {
let (x, y) = (x as f64 * dx, y as f64 * dy);
if !x.is_finite() || !y.is_finite() || x.abs() > MAX_STEP_REPEAT_OFFSET || y.abs() > MAX_STEP_REPEAT_OFFSET {
return Err(bad("edit.stepAndRepeat", format!("generated offsets must stay between -{MAX_STEP_REPEAT_OFFSET} and {MAX_STEP_REPEAT_OFFSET}")));
}
Ok(Vec2::new(x, y))
}
fn step_repeat_offsets(p: &Value) -> Result<Vec<Vec2>> {
let (dx, dy) = (step_repeat_delta(p, "dx", 12.0)?, step_repeat_delta(p, "dy", 12.0)?);
match (p.get("rows"), p.get("columns")) {
(None, None) => {
let count = step_repeat_count(p, "count", 1)?;
(1..=count).map(|k| checked_step_offset(dx, dy, k, k)).collect()
}
(Some(_), Some(_)) => {
let rows = step_repeat_count(p, "rows", 1)?;
let columns = step_repeat_count(p, "columns", 1)?;
let cells = rows
.checked_mul(columns)
.filter(|count| *count <= MAX_STEP_REPEAT_COPIES)
.ok_or_else(|| bad("edit.stepAndRepeat", format!("`rows` × `columns` must not exceed {MAX_STEP_REPEAT_COPIES}")))?;
let capacity = usize::try_from(cells.saturating_sub(1))
.map_err(|_| bad("edit.stepAndRepeat", "the requested grid is too large for this platform"))?;
let mut offsets = Vec::with_capacity(capacity);
for row in 0..rows {
for column in 0..columns {
if row != 0 || column != 0 {
offsets.push(checked_step_offset(dx, dy, column, row)?);
}
}
}
Ok(offsets)
}
_ => Err(bad("edit.stepAndRepeat", "`rows` and `columns` must be provided together")),
}
}
/// Existing roots to duplicate, with repeated/ancestor-overlapping targets collapsed. Returns an
/// error before cloning the document when the copies (including group descendants) exceed the
/// command's work budget.
fn checked_step_repeat_targets(d: &Document, ids: &[ItemId], copies: usize, max_work: usize) -> Result<Vec<ItemId>> {
let mut seen = HashSet::new();
let mut located = Vec::new();
for id in ids {
if seen.insert(*id)
&& let Some(loc) = d.find(*id)
{
located.push((*id, loc));
}
}
if located.is_empty() {
return Err(bad("edit.stepAndRepeat", "nothing to repeat"));
}
// A selected ancestor already deep-copies its descendants. Keeping a selected child as a
// second root would duplicate it twice and make the checked work disagree with the real work.
let roots: Vec<ItemId> = located
.iter()
.filter(|(_, loc)| {
!located.iter().any(|(_, parent)| parent.spread == loc.spread && parent.path.len() < loc.path.len() && loc.path.starts_with(&parent.path))
})
.map(|(id, _)| *id)
.collect();
let mut expanded = HashSet::new();
for id in &roots {
if let Some(item) = d.item(*id) {
item.walk(&mut |child| {
expanded.insert(child.id);
});
}
}
expanded
.len()
.checked_mul(copies)
.filter(|work| *work <= max_work)
.ok_or_else(|| bad("edit.stepAndRepeat", format!("a single command may create at most {max_work} items, including group contents")))?;
Ok(roots)
}
fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let offsets = step_repeat_offsets(p)?;
let ids = checked_step_repeat_targets(&s.doc()?.doc, &ids, offsets.len(), MAX_STEP_REPEAT_WORK)?;
s.edit(|d, sel| {
let src = d.clone();
let mut all = ids.clone();
for off in offsets {
for id in &ids {
let sr = src.find(*id).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0));
all.extend(duplicate_from(d, &src, &[*id], sr, off)?);
}
}
*sel = Selection::items(all.clone());
let copies: Vec<u64> = all.iter().skip(ids.len()).map(|i| i.0).collect();
Ok(json!({"created": copies.len(), "ids": copies}))
})
}
#[cfg(test)]
mod arrange_tests {
use super::*;
fn fixture<const N: usize>(layers: [usize; N]) -> (Session, [ItemId; N]) {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let first = s.doc().unwrap().doc.default_layer().0;
let second = s.execute("layer.new", &json!({"name": "Other layer"})).unwrap()["id"].as_u64().unwrap();
let layer_ids = [first, second];
let mut ids = [ItemId(0); N];
for (id, layer) in ids.iter_mut().zip(layers) {
// Overlap objects within each layer; separate layers spatially for hit testing.
let x = if layer == 0 { 0 } else { 200 };
*id = ItemId(s.execute("frame.create", &json!({"rect": [x, 0, x + 100, 100]})).unwrap()["id"].as_u64().unwrap());
s.execute("object.setLayer", &json!({"ids": [id.0], "layer": layer_ids[layer]})).unwrap();
}
(s, ids)
}
fn stack(s: &Session) -> Vec<ItemId> {
s.doc().unwrap().doc.spreads[0].items.iter().map(|it| it.id).collect()
}
fn snapshot(s: &Session) -> Value {
let st = s.doc().unwrap();
json!({"document": &*st.doc, "selection": &st.selection})
}
fn assert_items_unchanged(before: &Document, after: &Document) {
assert_eq!(serde_json::to_value(&before.layers).unwrap(), serde_json::to_value(&after.layers).unwrap());
for it in &before.spreads[0].items {
assert_eq!(serde_json::to_value(it).unwrap(), serde_json::to_value(after.item(it.id).unwrap()).unwrap());
}
}
#[test]
fn arrange_steps_past_other_layers_change_hit_order_and_undo() {
for direction in ["forward", "backward"] {
let (mut s, [a, x, y, b]) = fixture([0, 1, 1, 0]);
let selected = if direction == "forward" { a } else { b };
s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap();
let before = snapshot(&s);
let doc = s.doc().unwrap().doc.clone();
let undo_len = s.doc().unwrap().history.undo.len();
let hit = |s: &Session| s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0);
assert_eq!(hit(&s), Some(b));
s.execute("object.arrange", &json!({"to": direction})).unwrap();
assert_eq!(stack(&s), [b, x, y, a], "{direction}");
assert_eq!(hit(&s), Some(a), "the visible topmost object must change");
assert_items_unchanged(&doc, &s.doc().unwrap().doc);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
assert_eq!(s.doc().unwrap().selection.items, [selected]);
let after = snapshot(&s);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(snapshot(&s), before);
assert_eq!(hit(&s), Some(b));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(snapshot(&s), after);
assert_eq!(hit(&s), Some(a));
}
}
#[test]
fn arrange_multiple_selection_preserves_order_independent_of_input_order() {
// Layer 0: a, b, c, d. Layer 1: x, y. Arrays name the original stack indices.
let cases = [
("forward", [0, 2], [4, 1, 0, 3, 2, 5]), // a,b cross c as a block
("backward", [2, 4], [2, 1, 4, 3, 0, 5]), // b,c cross a as a block
("forward", [0, 4], [2, 1, 0, 3, 5, 4]), // separate selected blocks
("backward", [2, 5], [2, 1, 0, 3, 5, 4]),
("forward", [4, 5], [0, 1, 2, 3, 4, 5]), // front block is already at its endpoint
("backward", [0, 2], [0, 1, 2, 3, 4, 5]), // back block is already at its endpoint
];
for (direction, selected, expected) in cases {
for reverse in [false, true] {
let (mut s, ids) = fixture([0, 1, 0, 1, 0, 0]);
let doc = s.doc().unwrap().doc.clone();
let mut selected = selected.map(|i| ids[i].0).to_vec();
if reverse {
selected.reverse();
}
// Explicit command targets may contain duplicates: an item still moves once.
selected.push(selected[0]);
s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap();
assert_eq!(stack(&s), expected.map(|i| ids[i]), "{direction}, reverse={reverse}");
assert_items_unchanged(&doc, &s.doc().unwrap().doc);
}
}
}
#[test]
fn arrange_same_layer_steps_stop_at_layer_endpoints() {
let cases = [
("forward", vec![1], [0, 2, 1, 3, 4]),
("backward", vec![3], [0, 1, 3, 2, 4]),
("forward", vec![3], [0, 1, 2, 3, 4]),
("backward", vec![1], [0, 1, 2, 3, 4]),
("forward", vec![3, 1, 2], [0, 1, 2, 3, 4]),
("backward", vec![3, 1, 2], [0, 1, 2, 3, 4]),
];
for (direction, selected, expected) in cases {
let (mut s, ids) = fixture([1, 0, 0, 0, 1]);
let selected: Vec<_> = selected.into_iter().map(|i| ids[i].0).collect();
s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap();
assert_eq!(stack(&s), expected.map(|i| ids[i]));
}
for direction in ["forward", "backward"] {
let (mut s, [x, a, y]) = fixture([1, 0, 1]);
s.execute("object.arrange", &json!({"to": direction, "ids": [a.0]})).unwrap();
assert_eq!(stack(&s), [x, a, y], "only object on its layer");
}
}
#[test]
fn arrange_selection_on_two_layers_moves_each_layer_independently() {
for direction in ["forward", "backward"] {
let (mut s, [a, x, b, y]) = fixture([0, 1, 0, 1]);
let selected = if direction == "forward" { [x.0, a.0] } else { [b.0, y.0] };
s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap();
assert_eq!(stack(&s), [b, y, a, x]);
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a));
assert_eq!(doc.hit_item(0, Point::new(250.0, 50.0), 0.0), Some(x));
}
}
#[test]
fn arrange_menu_steps_use_same_layer_order() {
for command in ["object.bringForward", "object.sendBackward"] {
let (mut s, [a, x, b]) = fixture([0, 1, 0]);
let selected = if command == "object.bringForward" { a } else { b };
s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap();
s.execute(command, &json!({})).unwrap();
assert_eq!(stack(&s), [b, x, a]);
assert_eq!(s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a));
}
}
}
#[cfg(test)]
mod step_repeat_tests {
use super::*;
/// With nothing selected, fill and stroke set the colours new shapes are drawn with, as
/// InDesign's document defaults; frames for graphics and text keep theirs.
#[test]
fn with_nothing_selected_fill_and_stroke_are_for_new_shapes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let colors = |s: &Session, id: u64| {
let it = s.doc().unwrap().doc.item(ItemId(id)).unwrap();
(it.fill.swatch.clone(), it.stroke.swatch.clone(), it.stroke.weight)
};
let rect = |s: &mut Session| {
let id = s.execute("frame.create", &json!({"rect": [72, 72, 144, 144], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": []})).unwrap();
id
};
// New documents: no fill and a 1 pt black stroke.
let a = rect(&mut s);
assert_eq!(colors(&s, a), ("[None]".into(), "[Black]".into(), 1.0));
// A picked colour and Apply None, with nothing selected.
let r = s.execute("object.color", &json!({"color": "#7c1315"})).unwrap();
let red = r["swatch"].as_str().unwrap().to_string();
s.execute("object.stroke", &json!({"swatch": "[None]"})).unwrap();
let b = rect(&mut s);
assert_eq!(colors(&s, b), (red.clone(), "[None]".into(), 1.0), "the next shape has them");
assert_eq!(colors(&s, a).0, "[None]", "shapes drawn before keep theirs");
let line = s.execute("line.create", &json!({"a": [0, 0], "b": [100, 0]})).unwrap()["id"].as_u64().unwrap();
assert_eq!(colors(&s, line).1, "[None]");
// Pen and Pencil paths too.
let anchors = json!([{"p": [0, 200]}, {"p": [100, 250]}, {"p": [50, 300]}]);
let path = s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap();
assert_eq!(colors(&s, path), (red.clone(), "[None]".into(), 1.0));
s.execute("selection.set", &json!({"ids": []})).unwrap();
// A graphics frame keeps its object style's.
let g = s.execute("frame.create", &json!({"rect": [200, 72, 300, 144]})).unwrap()["id"].as_u64().unwrap();
assert_ne!(colors(&s, g).0, red);
s.execute("selection.set", &json!({"ids": []})).unwrap();
// Swap and Default.
s.execute("object.swapFillStroke", &json!({})).unwrap();
let c = &s.doc().unwrap().doc.settings.new_object;
assert_eq!((c.fill.as_str(), c.stroke.as_str()), ("[None]", red.as_str()));
s.execute("object.fill", &json!({"swatch": "[Paper]"})).unwrap();
assert_eq!(s.doc().unwrap().doc.settings.new_object.fill, "[Paper]");
s.execute("object.defaultFillStroke", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.settings.new_object, designcraft_doc::NewObjectColors::default());
// One undo step each.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.settings.new_object.fill, "[Paper]");
// With a selection, they still colour it and leave the defaults.
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap();
assert_eq!(colors(&s, a).0, "[Black]");
assert_eq!(s.doc().unwrap().doc.settings.new_object.fill, "[Paper]");
}
#[test]
fn swap_and_default_fill_stroke_are_one_undo_step_per_object() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = |v: Value| ItemId(v["id"].as_u64().unwrap());
let a = id(s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap());
let b = id(s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap());
let locked = id(s.execute("frame.create", &json!({"rect": [40, 0, 50, 10]})).unwrap());
s.execute("object.fill", &json!({"ids": [a.0], "swatch": "[Paper]", "tint": 0.5})).unwrap();
s.execute("object.stroke", &json!({"ids": [a.0], "swatch": "[Black]", "weight": 2})).unwrap();
s.execute("object.fill", &json!({"ids": [b.0, locked.0], "swatch": "[Black]"})).unwrap();
s.execute("object.stroke", &json!({"ids": [b.0, locked.0], "swatch": "[None]"})).unwrap();
s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap();
s.execute("selection.set", &json!({"ids": [a.0, b.0, locked.0]})).unwrap();
let colors = |s: &Session, i: ItemId| {
let it = s.doc().unwrap().doc.item(i).unwrap().clone();
(it.fill.swatch, it.fill.tint, it.stroke.swatch, it.stroke.tint)
};
let before = [colors(&s, a), colors(&s, b), colors(&s, locked)];
assert_eq!(s.execute("object.swapFillStroke", &json!({})).unwrap()["changed"], 2);
assert_eq!(colors(&s, a), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5));
assert_eq!(colors(&s, b), ("[None]".into(), 1.0, "[Black]".into(), 1.0));
assert_eq!(colors(&s, locked), before[2], "a locked object keeps its colours");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before, "one undo restores both");
s.execute("object.defaultFillStroke", &json!({})).unwrap();
for i in [a, b] {
assert_eq!(colors(&s, i), ("[None]".into(), 1.0, "[Black]".into(), 1.0));
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before);
s.execute("selection.set", &json!({"ids": [locked.0]})).unwrap();
assert!(s.execute("object.swapFillStroke", &json!({})).is_err(), "nothing unlocked to change");
}
#[test]
fn step_repeat_validates_counts_and_offsets() {
assert_eq!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES})).unwrap().len(), MAX_STEP_REPEAT_COPIES as usize);
assert_eq!(step_repeat_offsets(&json!({"rows": 25, "columns": 40})).unwrap().len(), 999);
assert!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES + 1})).is_err());
assert!(step_repeat_offsets(&json!({"rows": 500, "columns": 3})).is_err());
assert!(step_repeat_offsets(&json!({"rows": 3})).is_err());
assert!(step_repeat_offsets(&json!({"count": 2, "dx": f64::MAX})).is_err());
}
#[test]
fn step_repeat_counts_unique_items_and_group_contents() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = ItemId(s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap());
let b = ItemId(s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap()["id"].as_u64().unwrap());
let c = ItemId(s.execute("frame.create", &json!({"rect": [40, 0, 50, 10]})).unwrap()["id"].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
assert_eq!(checked_step_repeat_targets(d, &[a, a], 2, 2).unwrap(), vec![a]);
assert!(checked_step_repeat_targets(d, &[a, b, c], 1, 2).is_err());
assert_eq!(s.execute("edit.stepAndRepeat", &json!({"ids": [a.0, a.0], "count": 1})).unwrap()["created"], 1);
let group = ItemId(s.execute("object.group", &json!({"ids": [a.0, b.0]})).unwrap()["id"].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
assert_eq!(checked_step_repeat_targets(d, &[group, group, a], 1, 3).unwrap(), vec![group]);
assert!(checked_step_repeat_targets(d, &[group, a], 1, 2).is_err());
assert_eq!(s.execute("edit.stepAndRepeat", &json!({"ids": [group.0, a.0], "count": 1})).unwrap()["created"], 1);
}
}
#[cfg(test)]
mod polygon_tests {
use super::*;
#[test]
fn polygon_settings_make_stars() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("tool.polygonSettings", &json!({"sides": 5, "starInset": 50})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200], "shape": "polygon"})).unwrap()["id"].as_u64().unwrap();
let d = &s.doc().unwrap().doc;
let it = d.item(ItemId(id)).unwrap();
// A 5-point star has 10 vertices, fitted to the rect.
assert_eq!(it.path.subpaths[0].anchors.len(), 10);
let b = it.bounds();
assert!((b.x0 - 100.0).abs() < 1e-6 && (b.y1 - 200.0).abs() < 1e-6, "{b:?}");
// Explicit parameters win over the settings.
let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50], "shape": "polygon", "sides": 3, "starInset": 0})).unwrap()["id"]
.as_u64()
.unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3);
}
#[test]
fn polygon_sides_clamp_before_narrowing() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967301u64})).unwrap();
assert_eq!(r["sides"], 100);
let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967297u64})).unwrap();
assert_eq!(r["sides"], 100);
let id =
s.execute("frame.create", &json!({"rect": [0, 0, 50, 50], "shape": "polygon", "sides": 4294967301u64, "starInset": 0})).unwrap()["id"]
.as_u64()
.unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 100);
}
}
#[cfg(test)]
mod scale_tests {
use super::*;
#[test]
fn scale_strokes_and_dimensions_with_stroke() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [100, 100, 200, 150]})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
s.execute("object.stroke", &json!({"weight": 4, "swatch": "[Black]"})).unwrap();
let weight = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().stroke.weight;
let bounds = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().bounds();
s.execute("transform.scale", &json!({"sx": 2.0})).unwrap();
assert_eq!(weight(&s), 8.0);
assert_eq!(bounds(&s), Rect::new(50.0, 75.0, 250.0, 175.0));
s.execute("prefs.set", &json!({"scaleStrokes": false})).unwrap();
s.execute("transform.scale", &json!({"sx": 0.5})).unwrap();
assert_eq!(weight(&s), 8.0);
assert!(s.execute("prefs.set", &json!({"nope": 1})).is_err());
// Width 108 with an 8 pt centred stroke: the path is 100 wide.
s.execute("transform.set", &json!({"width": 108})).unwrap();
assert!((bounds(&s).width() - 100.0).abs() < 1e-9, "{:?}", bounds(&s));
s.execute("prefs.set", &json!({"dimensionsIncludeStroke": false})).unwrap();
s.execute("transform.set", &json!({"width": 120})).unwrap();
assert!((bounds(&s).width() - 120.0).abs() < 1e-9);
}
fn text_frame(s: &mut Session, rect: [f64; 4], text: &str) -> (ItemId, StoryId) {
let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text})).unwrap();
(ItemId(r["id"].as_u64().unwrap()), StoryId(r["story"].as_u64().unwrap()))
}
fn props_at(s: &Session, story: StoryId, pos: usize) -> (designcraft_doc::ParaProps, designcraft_doc::CharProps) {
let d = &s.doc().unwrap().doc;
let st = d.story(story).unwrap();
let (pp, base) = d.styles.resolve_para(&st.paras[st.para_at(pos)]);
(pp, d.styles.resolve_char(&base, st.char_format_at(pos)))
}
/// InDesign's default When Scaling: Apply to Content: Cmd+Shift-dragging a text frame's handle
/// sets the text larger and leaves the frame at 100% (Dansky's Exclude Overlap tutorial, 2:16).
#[test]
fn scaling_a_text_frame_scales_its_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let (f, story) = text_frame(&mut s, [100.0, 100.0, 300.0, 200.0], "Learn\nmore");
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 5})).unwrap();
s.execute("type.char", &json!({"attrs": {"size": 20, "leading": {"kind": "points", "value": 24}, "baselineShift": 2, "tracking": -50}}))
.unwrap();
s.execute("type.para", &json!({"attrs": {"leftIndent": 4, "spaceAfter": 6}})).unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
// The handle drag: from the top-left, twice as wide and high.
s.execute("transform.resize", &json!({"ids": [f.0], "from": [100, 100, 300, 200], "to": [100, 100, 500, 300], "content": true})).unwrap();
let (pp, cp) = props_at(&s, story, 0);
assert_eq!(
(cp.size, cp.leading, cp.baseline_shift, cp.tracking, cp.h_scale),
(40.0, designcraft_doc::Leading::Points(48.0), 4.0, -50.0, 1.0)
);
assert_eq!((pp.left_indent, pp.space_after), (8.0, 12.0));
// The second paragraph's size came from the paragraph style: it is set too.
assert_eq!(props_at(&s, story, 7).1.size, 24.0);
let it = s.doc().unwrap().doc.item(f).unwrap().clone();
assert_eq!(it.bounds(), Rect::new(100.0, 100.0, 500.0, 300.0));
let scale = s.execute("transform.info", &json!({"ids": [f.0]})).unwrap();
assert_eq!((scale["scaleX"].as_f64(), scale["scaleY"].as_f64()), (Some(100.0), Some(100.0)), "{scale}");
// Wider than high: the horizontal scale takes the difference.
s.execute("transform.scale", &json!({"ids": [f.0], "sx": 1.0, "sy": 0.5})).unwrap();
let (_, cp) = props_at(&s, story, 0);
assert_eq!((cp.size, cp.h_scale), (20.0, 2.0));
// The Transform panel's scale fields do the same, and show 100% afterwards.
s.execute("transform.set", &json!({"ids": [f.0], "scaleX": 50, "scaleY": 50})).unwrap();
assert_eq!(props_at(&s, story, 0).1.size, 10.0);
// One undo step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(props_at(&s, story, 0).1.size, 20.0);
}
/// A threaded story, a rotated scale and a flip keep the old behaviour: the frame is
/// transformed as a whole and the text keeps its size.
#[test]
fn threaded_and_flipped_text_frames_scale_as_a_whole() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let (a, story) = text_frame(&mut s, [100.0, 100.0, 200.0, 140.0], "Some threaded text");
let (b, _) = text_frame(&mut s, [100.0, 200.0, 200.0, 240.0], "");
s.execute("text.thread", &json!({"from": a.0, "to": b.0})).unwrap();
s.execute("transform.scale", &json!({"ids": [a.0], "sx": 2.0})).unwrap();
assert_eq!(props_at(&s, story, 0).1.size, 12.0);
let (c, story) = text_frame(&mut s, [300.0, 100.0, 400.0, 140.0], "Flip");
s.execute("transform.scale", &json!({"ids": [c.0], "sx": -1.0, "sy": 1.0})).unwrap();
assert_eq!(props_at(&s, story, 0).1.size, 12.0);
assert!(s.doc().unwrap().doc.item(c).unwrap().xf.as_coeffs()[0] < 0.0);
// A frame holding a table: the table would keep its size while the text grew.
let (t, story) = text_frame(&mut s, [300.0, 300.0, 500.0, 500.0], "Grid");
s.execute("text.select", &json!({"story": story.0, "anchor": 4, "focus": 4})).unwrap();
s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
s.execute("transform.scale", &json!({"ids": [t.0], "sx": 2.0})).unwrap();
assert_eq!(props_at(&s, story, 0).1.size, 12.0);
assert!((s.doc().unwrap().doc.item(t).unwrap().xf.as_coeffs()[0] - 2.0).abs() < 1e-9);
}
}
#[cfg(test)]
mod gradient_tests {
use super::*;
#[test]
fn gradient_tool_vector_and_unnamed_gradients() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [100, 100, 300, 200]})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let swatches = |s: &Session| s.doc().unwrap().doc.swatches.len();
let n = swatches(&s);
// The default White→Black gradient, dragged from the left edge to the middle.
let r = s.execute("object.gradient", &json!({"from": [100, 150], "to": [200, 150]})).unwrap();
assert_eq!(r["kind"], "linear");
assert_eq!(r["stops"][1]["color"], "#000000");
assert_eq!(swatches(&s), n + 1, "an unnamed gradient");
let fill = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().fill.clone();
assert_eq!(fill(&s).gradient_vector, Some([100.0, 150.0, 200.0, 150.0]));
// Same gradient again: no new swatch. Reverse / radial: a new unnamed gradient.
s.execute("object.gradient", &json!({})).unwrap();
assert_eq!(swatches(&s), n + 1);
let r = s.execute("object.gradient", &json!({"kind": "radial", "reverse": true})).unwrap();
assert_eq!(r["kind"], "radial");
assert_eq!(r["stops"][0]["color"], "#000000");
let r = s
.execute(
"object.gradient",
&json!({"stops": [{"location": 0, "color": "#ff0000"}, {"location": 60, "color": "#0000ff", "midpoint": 30}]}),
)
.unwrap();
assert_eq!(r["stops"][1]["location"], 60.0);
assert_eq!(r["stops"][1]["midpoint"], 30.0);
assert!(s.execute("object.gradient", &json!({"stops": [{"location": 0, "color": "#ff0000"}]})).is_err());
// Scaling moves the vector with the shape.
s.execute("transform.scale", &json!({"sx": 2.0})).unwrap();
assert_eq!(fill(&s).gradient_vector, Some([0.0, 150.0, 200.0, 150.0]));
// An angle replaces the vector.
s.execute("object.gradient", &json!({"angle": 90})).unwrap();
assert_eq!(fill(&s).gradient_vector, None);
assert_eq!(fill(&s).gradient_angle, Some(90.0));
}
#[test]
fn swatches_dropped_on_stops_keep_their_cmyk() {
use designcraft_color::Color;
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [100, 100, 300, 200]})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
// LYH Studio episode 6: magenta on the left stop, cyan dropped at 68.636 %, a grey third.
let r = s
.execute(
"object.gradient",
&json!({"stops": [
{"location": 0, "swatch": "C=0 M=100 Y=0 K=0"},
{"location": 68.636, "swatch": "C=100 M=0 Y=0 K=0"},
{"location": 100, "color": {"gray": 25}}
]}),
)
.unwrap();
let d = &s.doc().unwrap().doc;
let g = designcraft_color::swatch::resolve_gradient(&d.swatches, r["swatch"].as_str().unwrap()).unwrap();
assert_eq!(d.item(id).unwrap().fill.swatch, r["swatch"].as_str().unwrap());
assert_eq!(g.stops[0].color, Color::cmyk(0.0, 1.0, 0.0, 0.0));
assert_eq!(g.stops[1].color, Color::cmyk(1.0, 0.0, 0.0, 0.0));
assert!((g.stops[1].offset - 0.68636).abs() < 1e-6);
assert_eq!(g.stops[2].color, Color::gray(0.25));
// Not a colour: refused, nothing changes.
for bad in ["[None]", "Missing", r["swatch"].as_str().unwrap()] {
let p = json!({"stops": [{"location": 0, "swatch": bad}, {"location": 100, "color": "#000000"}]});
assert!(s.execute("object.gradient", &p).is_err(), "{bad}");
}
}
}
#[cfg(test)]
mod reference_point_tests {
use super::*;
#[test]
fn rotate_and_flip_turn_about_the_reference_point() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = ItemId(s.execute("frame.create", &json!({"rect": [100, 100, 200, 150]})).unwrap()["id"].as_u64().unwrap());
let item = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().clone();
// 90° counterclockwise about the top-left: that corner stays, the frame swings up.
s.execute("transform.rotate", &json!({"angle": 90, "ref": 0, "ids": [id.0]})).unwrap();
let it = item(&s);
assert!((it.xf * Point::new(100.0, 100.0) - Point::new(100.0, 100.0)).hypot() < 1e-9, "{:?}", it.xf);
let b = it.bounds();
assert!((b.x0 - 100.0).abs() < 1e-9 && (b.y0 - 0.0).abs() < 1e-9 && (b.x1 - 150.0).abs() < 1e-9 && (b.y1 - 100.0).abs() < 1e-9, "{b:?}");
// Flip horizontally about the right edge's middle: the frame lands to the right of it.
s.execute("transform.flip", &json!({"axis": "horizontal", "ref": 5, "ids": [id.0]})).unwrap();
let b = item(&s).bounds();
assert!((b.x0 - 150.0).abs() < 1e-9 && (b.x1 - 200.0).abs() < 1e-9 && (b.y0 - 0.0).abs() < 1e-9, "{b:?}");
}
}
#[cfg(test)]
mod again_tests {
use super::*;
#[test]
fn transform_again_sequence_and_clear() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = ItemId(s.execute("frame.create", &json!({"rect": [100, 100, 200, 150]})).unwrap()["id"].as_u64().unwrap());
let b = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().bounds();
assert!(s.execute("transform.again", &json!({})).is_err());
s.execute("transform.move", &json!({"dx": 10, "dy": 0})).unwrap();
s.execute("transform.again", &json!({})).unwrap();
assert_eq!(b(&s).x0, 120.0);
s.execute("transform.rotate", &json!({"angle": 90})).unwrap();
// Sequence: move 10 then rotate 90 again.
s.execute("transform.again", &json!({"sequence": true})).unwrap();
// The 100×50 frame is now 180° round from where it started: still 100 wide.
assert!((b(&s).width() - 100.0).abs() < 1e-6, "{:?}", b(&s));
// Clear Transformations: 180° in total → upright again, same centre.
let c = b(&s).center();
s.execute("transform.clear", &json!({})).unwrap();
let it = s.doc().unwrap().doc.item(id).unwrap().clone();
assert_eq!(it.xf.as_coeffs()[..4], [1.0, 0.0, 0.0, 1.0]);
assert!((it.bounds().center() - c).hypot() < 1e-6);
assert!((it.bounds().width() - 100.0).abs() < 1e-6);
// Step and repeat via Transform Again on a moved copy.
let n = s.doc().unwrap().doc.spreads[0].items.len();
s.execute("transform.move", &json!({"dx": 0, "dy": 60, "copy": true})).unwrap();
s.execute("transform.again", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.spreads[0].items.len(), n + 2);
let ys: Vec<f64> = d.spreads[0].items[n..].iter().map(|i| i.bounds().y0.round()).collect();
assert_eq!(ys[1] - ys[0], 60.0, "{ys:?}");
}
}
#[cfg(test)]
mod label_tests {
use super::*;
#[test]
fn script_label_alt_text_and_group_transparency() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [60, 10, 90, 50]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.label", &json!({"label": "price", "ids": [a, b]})).unwrap();
s.execute("object.altText", &json!({"text": "A red square", "ids": [a]})).unwrap();
assert_eq!(s.execute("object.findByLabel", &json!({"label": "price"})).unwrap()["ids"], json!([a, b]));
assert_eq!(s.execute("object.findByLabel", &json!({"label": "nope"})).unwrap()["ids"], json!([]));
assert_eq!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().alt_text, "A red square");
s.execute("object.transparencyGroup", &json!({"knockout": true, "ids": [b]})).unwrap();
let it = s.doc().unwrap().doc.item(ItemId(b)).unwrap().clone();
assert!(it.knockout && !it.isolate);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.doc().unwrap().doc.item(ItemId(b)).unwrap().knockout);
}
}
#[cfg(test)]
mod light_tests {
use super::*;
#[test]
fn global_light_moves_shadows_that_use_it() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.dropShadow", &json!({"on": true, "angle": 45, "ids": [a]})).unwrap();
let angle = |s: &Session| {
let d = &s.doc().unwrap().doc;
let ds = &d.item(ItemId(a)).unwrap().effects.drop_shadow;
d.light_angle(ds.angle, ds.global_light)
};
s.execute("object.globalLight", &json!({"angle": 200})).unwrap();
assert_eq!(angle(&s), 45.0, "own angle");
s.execute("object.dropShadow", &json!({"globalLight": true, "on": true, "ids": [a]})).unwrap();
assert_eq!(angle(&s), -160.0, "normalised to -180..180");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(angle(&s), 45.0);
}
/// The Effects dialog's Angle with Use Global Light on changes the global light, with the
/// effects, in one undo step.
#[test]
fn effects_sets_the_global_light_with_the_effects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [60, 10, 90, 50]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.dropShadow", &json!({"on": true, "globalLight": true, "ids": [b]})).unwrap();
let before = s.doc().unwrap().doc.settings.global_light;
s.execute("object.effects", &json!({"ids": [a], "effects": {"dropShadow": {"on": true, "globalLight": true}}, "globalLight": 30})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.settings.global_light, 30.0);
let other = &d.item(ItemId(b)).unwrap().effects.drop_shadow;
assert_eq!(d.light_angle(other.angle, other.global_light), 30.0, "every shadow on Global Light follows");
assert!(s.execute("object.effects", &json!({"ids": [a], "globalLight": "up"})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.settings.global_light, before, "one undo step");
}
}
#[cfg(test)]
mod transform_values_tests {
use super::*;
#[test]
fn absolute_rotation_shear_and_totals() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 150]})).unwrap()["id"].as_u64().unwrap();
let info = |s: &mut Session, id: u64| s.execute("transform.info", &json!({"ids": [id]})).unwrap();
let centre = |s: &Session| s.doc().unwrap().doc.item(ItemId(a)).unwrap().bounds().center();
let c0 = centre(&s);
s.execute("transform.set", &json!({"rotation": 30, "ids": [a]})).unwrap();
s.execute("transform.set", &json!({"rotation": 30, "ids": [a]})).unwrap();
assert_eq!(info(&mut s, a)["rotation"], 30.0, "absolute, not cumulative");
assert!((centre(&s) - c0).hypot() < 1e-9, "about the centre reference point");
s.execute("transform.set", &json!({"shear": 20, "ids": [a]})).unwrap();
let i = info(&mut s, a);
assert_eq!((i["rotation"].as_f64(), i["shear"].as_f64()), (Some(30.0), Some(20.0)));
assert_eq!(i["scaleX"], 100.0);
// Nested: a group rotated 10° around the rotated frame.
let b = s.execute("frame.create", &json!({"rect": [300, 100, 350, 150]})).unwrap()["id"].as_u64().unwrap();
let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
s.execute("transform.rotate", &json!({"angle": 10, "ids": [g]})).unwrap();
assert_eq!(info(&mut s, a)["rotation"], 40.0, "totals");
s.execute("prefs.set", &json!({"transformationsAreTotals": false})).unwrap();
assert_eq!(info(&mut s, a)["rotation"], 30.0, "relative to the group");
s.execute("transform.set", &json!({"rotation": 0, "shear": 0, "ids": [a]})).unwrap();
s.execute("prefs.set", &json!({"transformationsAreTotals": true})).unwrap();
assert_eq!(info(&mut s, a)["rotation"], 10.0);
// Scale is relative to what's shown: a frame always shows 100%.
s.execute("transform.set", &json!({"scaleX": 200, "ids": [b]})).unwrap();
assert_eq!(info(&mut s, b)["scaleX"], 100.0);
}
}
#[cfg(test)]
mod attributes_tests {
use super::*;
#[test]
fn attributes_panel_flags() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.attributes", &json!({"overprintFill": true, "overprintGap": true, "nonprinting": true})).unwrap();
let it = s.doc().unwrap().doc.item(ItemId(a)).unwrap().clone();
assert!(it.fill.overprint && it.stroke.gap_overprint && it.nonprinting && !it.stroke.overprint);
// IDML keeps them.
let bytes = designcraft_idml::export_idml(&s.doc().unwrap().doc);
let back = designcraft_idml::import_idml(&bytes).unwrap();
let it = back.spreads.iter().flat_map(|sp| sp.items.iter()).find(|i| i.fill.overprint).expect("overprint fill survives");
assert!(it.stroke.gap_overprint);
}
}
#[cfg(test)]
mod fitting_tests {
use super::*;
#[test]
fn separations_come_from_source_colours() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let c = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].clone();
s.execute("object.fill", &json!({"swatch": "C=100 M=0 Y=0 K=0", "ids": [c]})).unwrap();
let k = s.execute("frame.create", &json!({"rect": [300, 100, 400, 200]})).unwrap()["id"].clone();
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [k]})).unwrap();
let d = s.doc().unwrap().doc.clone();
let plate = |p: u8| {
let mut rr = designcraft_render::Renderer::new();
rr.threads = 0;
rr.render_page(&d, &s.cache, 0, 1.0, false, &designcraft_render::RenderOptions { plate: Some(p), ..Default::default() }).unwrap()
};
let (cyan, black) = (plate(0), plate(3));
// Cyan ink only on the cyan frame; black ink only on the black one (no rich-black guess).
assert!(cyan.pixel(150, 150)[0] < 10 && cyan.pixel(350, 150)[0] > 245, "{:?} {:?}", cyan.pixel(150, 150), cyan.pixel(350, 150));
assert!(black.pixel(350, 150)[0] < 10 && black.pixel(150, 150)[0] > 245);
assert!(cyan.pixel(20, 20)[0] > 245, "paper has no ink");
}
#[test]
fn inner_glow_bevel_and_satin_change_the_inside() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].clone();
s.execute("object.fill", &json!({"swatch": "C=100 M=0 Y=0 K=0", "ids": [id]})).unwrap();
let shot = |s: &Session| {
let d = s.doc().unwrap().doc.clone();
let mut rr = designcraft_render::Renderer::new();
rr.threads = 0;
rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()
};
let plain = shot(&s);
// Inner glow (paper colour, screened): the edge lightens, the centre doesn't.
s.execute("object.innerGlow", &json!({"ids": [id], "on": true, "size": 20})).unwrap();
let g = shot(&s);
assert!(g.pixel(103, 200)[0] > plain.pixel(103, 200)[0] + 30, "edge lighter");
assert_eq!(g.pixel(200, 200), plain.pixel(200, 200));
s.execute("object.innerGlow", &json!({"ids": [id], "on": false})).unwrap();
// Bevel: lit and shaded sides differ.
s.execute("object.bevel", &json!({"ids": [id], "on": true, "size": 20, "angle": 120, "globalLight": false})).unwrap();
let b = shot(&s);
let (top_left, bottom_right) = (b.pixel(104, 104), b.pixel(296, 296));
assert!(top_left[1] as i32 > bottom_right[1] as i32 + 30, "{top_left:?} {bottom_right:?}");
s.execute("object.bevel", &json!({"ids": [id], "on": false})).unwrap();
// Satin darkens somewhere inside.
s.execute("object.satin", &json!({"ids": [id], "on": true, "distance": 40, "size": 10})).unwrap();
let st = shot(&s);
let darker = (110..290)
.step_by(10)
.flat_map(|x| (110..290).step_by(10).map(move |y| (x, y)))
.any(|(x, y)| (st.pixel(x, y)[1] as i32) < plain.pixel(x, y)[1] as i32 - 30);
assert!(darker);
s.execute("object.satin", &json!({"ids": [id], "on": false})).unwrap();
// Directional feather: only the left edge fades.
s.execute("object.directionalFeather", &json!({"ids": [id], "on": true, "widths": [0, 40, 0, 0]})).unwrap();
let df = shot(&s);
assert!(df.pixel(102, 200)[0] > 200, "left edge faded to paper: {:?}", df.pixel(102, 200));
assert_eq!(df.pixel(298, 200), plain.pixel(298, 200), "right edge untouched");
// A knockout group exports as an image too.
let other = s.execute("frame.create", &json!({"rect": [400, 100, 450, 150]})).unwrap()["id"].clone();
let other2 = s.execute("frame.create", &json!({"rect": [420, 120, 470, 170]})).unwrap()["id"].clone();
let g = s.execute("object.group", &json!({"ids": [other, other2]})).unwrap()["id"].clone();
s.execute("object.transparencyGroup", &json!({"ids": [g], "knockout": true})).unwrap();
let r = s.execute("file.exportPdf", &json!({})).unwrap();
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("2 object(s)")), "{r}");
// PDF export carries effects (as an image of the object).
let r = s.execute("file.exportPdf", &json!({})).unwrap();
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("soft effects")), "{r}");
let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap());
assert!(String::from_utf8_lossy(&bytes).contains("/Subtype/Image"));
}
#[test]
fn a_flat_line_resizes_along_its_length() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("line.create", &json!({"a": [100, 100], "b": [200, 100]})).unwrap()["id"].as_u64().unwrap();
s.execute("transform.resize", &json!({"ids": [id], "from": [100, 100, 200, 100], "to": [100, 100, 300, 100]})).unwrap();
let r = s.doc().unwrap().doc.item(ItemId(id)).unwrap().bounds();
assert!((r.x0 - 100.0).abs() < 1e-9 && (r.x1 - 300.0).abs() < 1e-9 && (r.y0 - 100.0).abs() < 1e-9 && r.height().abs() < 1e-9, "{r:?}");
}
#[test]
fn live_distribute_spreads_objects_without_resizing() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [100, 100, 120, 120]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [180, 100, 200, 120]})).unwrap()["id"].as_u64().unwrap();
s.execute("transform.resize", &json!({"ids": [a, b], "from": [100, 100, 200, 120], "to": [100, 100, 300, 120], "distribute": true})).unwrap();
let d = &s.doc().unwrap().doc;
let (ra, rb) = (d.item(ItemId(a)).unwrap().bounds(), d.item(ItemId(b)).unwrap().bounds());
assert!((ra.width() - 20.0).abs() < 1e-9 && (rb.width() - 20.0).abs() < 1e-9, "sizes kept");
assert!((ra.center().x - 120.0).abs() < 1e-9, "{ra:?}");
assert!((rb.center().x - 280.0).abs() < 1e-9, "{rb:?}");
}
#[test]
fn frame_fitting_options_align_crop_and_auto_fit() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// A 40×20 pt image placed into a 200×200 frame.
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap();
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "a.png", "frame": f})).unwrap();
let content = |s: &Session| {
let it = s.doc().unwrap().doc.item(ItemId(f)).unwrap().clone();
let Content::Graphic(g) = &it.content else { panic!("graphic") };
it.xf.transform_rect_bbox(g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1)))
};
// Fit proportionally from the top-left: 200 wide, 100 high, against the top.
s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "align": 0})).unwrap();
let c = content(&s);
assert!((c.x0 - 100.0).abs() < 1e-6 && (c.y0 - 100.0).abs() < 1e-6 && (c.width() - 200.0).abs() < 1e-6, "{c:?}");
// Crop 10 pt on every side and fit again: the content spreads past the frame.
s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "crop": 10, "align": 4})).unwrap();
let c = content(&s);
assert!((c.width() - 220.0).abs() < 1e-6, "{c:?}");
// Auto-fit: resizing the frame refits the content.
s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fillProportionally", "crop": 0})).unwrap();
s.execute("transform.resize", &json!({"from": [100, 100, 300, 300], "to": [100, 100, 500, 300], "ids": [f]})).unwrap();
let c = content(&s);
assert!((c.width() - 400.0).abs() < 1e-6, "fills the wider frame: {c:?}");
// IDML keeps the options.
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap();
let g = back.spreads.iter().flat_map(|sp| sp.items.iter()).find_map(|i| i.graphic().cloned()).unwrap();
assert_eq!(g.auto_fit, designcraft_doc::Fitting::FillProportionally);
}
}
#[cfg(test)]
mod primary_frame_tests {
use super::*;
#[test]
fn toggle_primary_text_frame() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 300], "content": "text", "text": "x", "caret": false})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("object.primaryTextFrame", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.settings.primary_story.map(|x| x.0), Some(sid));
s.execute("object.primaryTextFrame", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.settings.primary_story, None, "toggles off");
let g = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].clone();
assert!(s.execute("object.primaryTextFrame", &json!({"ids": [g]})).is_err());
}
}
#[cfg(test)]
mod clipping_tests {
use super::*;
#[test]
fn clipping_path_from_alpha_traces_the_shape() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// 40×40 image: an opaque disc on transparency.
let mut px = vec![0u8; 40 * 40 * 4];
for y in 0..40 {
for x in 0..40 {
let (dx, dy) = (x as f64 + 0.5 - 20.0, y as f64 + 0.5 - 20.0);
if (dx * dx + dy * dy).sqrt() < 15.0 {
px[(y * 40 + x) * 4..(y * 40 + x) * 4 + 4].copy_from_slice(&[200, 0, 0, 255]);
}
}
}
let png = designcraft_render::Rendered { width: 40, height: 40, pixels: px }.to_png();
let f = s.execute("frame.create", &json!({"rect": [100, 100, 140, 140]})).unwrap()["id"].as_u64().unwrap();
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "disc.png", "frame": f})).unwrap();
s.execute("object.clippingPath", &json!({"ids": [f], "tolerance": 0.5})).unwrap();
let it = s.doc().unwrap().doc.item(ItemId(f)).unwrap().clone();
let b = it.bounds();
// The disc spans 5..35 of the 40 px image placed at 100..140.
assert!((b.x0 - 105.0).abs() < 1.5 && (b.x1 - 135.0).abs() < 1.5, "{b:?}");
assert_eq!(it.shape, Shape::Path);
assert!(s.execute("object.clippingPath", &json!({"ids": [f], "threshold": 255})).is_err());
}
#[test]
fn clipping_path_options_report_invert_and_remove_the_clip() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let png = designcraft_render::Rendered { width: 4, height: 4, pixels: vec![255; 64] }.to_png();
let f = s.execute("frame.create", &json!({"rect": [100, 100, 140, 140]})).unwrap()["id"].as_u64().unwrap();
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "white.png", "frame": f})).unwrap();
let get = |s: &mut Session| s.execute("object.clippingPathOptions", &json!({"ids": [f]})).unwrap()["items"][0].clone();
assert_eq!(get(&mut s)["type"], "none");
// As an imported file would carry it.
let triangle = designcraft_geom::PathData::single(designcraft_geom::path::SubPath::polyline(
&[Point::new(0.0, 4.0), Point::new(2.0, 0.0), Point::new(4.0, 4.0)],
true,
));
s.edit(|d, _| {
if let Some(Content::Graphic(g)) = d.item_mut(ItemId(f)).map(|it| &mut it.content) {
g.clip = Some(Arc::new(designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::PhotoshopPath, triangle)));
}
Ok(json!({}))
})
.unwrap();
let c = get(&mut s);
assert_eq!((c["type"].as_str(), c["anchors"].as_u64(), c["invert"].as_bool()), (Some("photoshopPath"), Some(3), Some(false)));
s.execute("object.clippingPathOptions", &json!({"ids": [f], "invert": true})).unwrap();
assert_eq!(get(&mut s)["invert"], true);
assert_eq!(s.execute("object.clippingPathOptions", &json!({"ids": [f], "type": "none"})).unwrap()["changed"], 1);
assert_eq!(get(&mut s)["type"], "none");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(get(&mut s)["type"], "photoshopPath");
assert!(s.execute("object.clippingPathOptions", &json!({"ids": [f], "type": "alphaChannel"})).is_err());
assert!(s.execute("object.clippingPathOptions", &json!({"ids": [f], "invert": "yes"})).is_err());
}
}
#[cfg(test)]
mod gap_tests {
use super::*;
#[test]
fn moving_a_gap_resizes_both_sides() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 300]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [220, 100, 320, 300]})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("gap.move", &json!({"at": [210, 200], "delta": 30})).unwrap();
assert_eq!((r["axis"].as_str(), r["changed"].as_u64()), (Some("x"), Some(2)));
let bb = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().bounds();
assert_eq!((bb(&s, a).x1, bb(&s, b).x0), (230.0, 250.0), "the gap moved, its width kept");
assert_eq!(bb(&s, b).x1, 320.0);
assert!(s.execute("gap.move", &json!({"at": [150, 200], "delta": 5})).is_err(), "inside an object");
}
}
#[cfg(test)]
mod line_end_tests {
use super::*;
/// "One\rTwo" made one paragraph (the CR stayed in the text) instead of two.
#[test]
fn carriage_returns_separate_paragraphs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One\rTwo\r\nThree"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let story = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!(story(&s).text, "One\nTwo\nThree");
assert_eq!(story(&s).paras.len(), 3);
// The caret is at the end: typed text gets the same treatment.
s.execute("text.insert", &json!({"text": "\rFour", "raw": true})).unwrap();
assert_eq!(story(&s).text, "One\nTwo\nThree\nFour");
assert_eq!(story(&s).paras.len(), 4);
// Editing a range or the whole story (scripts written for InDesign end paragraphs with \r).
s.execute("story.replaceRange", &json!({"story": sid.0, "start": 0, "end": 3, "text": "Zero\rOne"})).unwrap();
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("Zero\nOne\nTwo\nThree\nFour", 5));
s.execute("story.setText", &json!({"story": sid.0, "text": "A\r\nB\rC"})).unwrap();
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\nB\nC", 3));
// Notes, made and edited.
s.execute("text.select", &json!({"story": sid.0, "anchor": 1})).unwrap();
let n = s.execute("note.new", &json!({"text": "One\rTwo"})).unwrap();
let note = |s: &mut Session| s.execute("note.list", &json!({})).unwrap()[0]["text"].clone();
assert_eq!(note(&mut s), "One\nTwo");
s.execute("note.edit", &json!({"story": n["story"], "id": n["id"], "text": "Three\r\nFour"})).unwrap();
assert_eq!(note(&mut s), "Three\nFour");
}
/// "One\u{B}Two" kept the vertical tab, drawn as a missing-glyph box, where Word and
/// PowerPoint text (Chr(11)) means a forced line break.
#[test]
fn vertical_tabs_are_forced_line_breaks() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One\u{B}Two"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let story = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("One\u{2028}Two", 1));
s.execute("text.insert", &json!({"text": "\u{B}Three", "raw": true})).unwrap();
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("One\u{2028}Two\u{2028}Three", 1));
s.execute("story.setText", &json!({"story": sid.0, "text": "A\u{B}B\rC"})).unwrap();
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\u{2028}B\nC", 2));
}
}
#[cfg(test)]
mod named_target_tests {
use super::*;
/// With a text caret (or nothing) selected, a command given `ids` failed
/// with "command `object.textFrameOptions` is not available right now: nothing selected".
#[test]
fn ids_act_without_a_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap();
assert!(s.doc().unwrap().selection.items.is_empty(), "frame.create leaves a text caret");
let e = s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap_err().to_string();
assert!(e.contains("nothing selected"), "{e}");
s.execute("object.textFrameOptions", &json!({"ids": [r["id"]], "columns": 2})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let columns = |s: &Session| s.doc().unwrap().doc.item(id).and_then(|i| i.text_frame().map(|t| t.options.columns));
assert_eq!(columns(&s), Some(2));
s.execute("transform.set", &json!({"id": r["id"], "x": 100, "ref": 0})).unwrap();
let e = s.execute("object.textFrameOptions", &json!({"ids": [99999], "columns": 1})).unwrap_err().to_string();
assert!(e.contains("no object with id 99999"), "{e}");
assert_eq!(columns(&s), Some(2));
// An empty `ids` names nothing (as `targets` reads it), even beside an `id`.
let e = s.execute("object.textFrameOptions", &json!({"ids": [], "id": r["id"], "columns": 3})).unwrap_err().to_string();
assert!(e.contains("nothing selected"), "{e}");
assert_eq!(columns(&s), Some(2));
// Commands documented with `ids` honour `id` too.
assert_eq!(s.execute("conveyor.collect", &json!({"id": r["id"]})).unwrap()["count"], 1);
}
}
#[cfg(test)]
mod column_rule_tests {
use super::*;
#[test]
fn column_rules_set_validate_undo_and_inspect() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let opts = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone();
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": 3, "inset": [1, 2, 3, 4]})).unwrap();
let before = opts(&s);
// An unknown swatch or a bad weight is an error and changes nothing.
let e = s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columnRule": true, "columnRuleColor": "Mauve"})).unwrap_err();
assert!(e.to_string().contains("Mauve"), "{e}");
assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columnRuleWeight": -1})).is_err());
assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columnRuleTint": 2})).is_err());
assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columnRuleTopInset": "x"})).is_err());
assert_eq!(opts(&s), before);
s.execute(
"object.textFrameOptions",
&json!({"ids": [id.0], "columnRule": true, "columnRuleWeight": 0.5, "columnRuleColor": "C=100 M=0 Y=0 K=0", "columnRuleTint": 0.5,
"columnRuleOffset": -2, "columnRuleTopInset": 4, "columnRuleBottomInset": 6}),
)
.unwrap();
let o = opts(&s);
assert!(o.column_rule && o.column_rule_weight == 0.5 && o.column_rule_color == "C=100 M=0 Y=0 K=0");
assert_eq!(o.inset, [1.0, 2.0, 3.0, 4.0], "untouched options stay");
assert_eq!((o.column_rule_tint, o.column_rule_offset, o.column_rule_top_inset, o.column_rule_bottom_inset), (0.5, -2.0, 4.0, 6.0));
let item = s.execute("document.inspect", &json!({})).unwrap()["spreads"][0]["items"]
.as_array()
.unwrap()
.iter()
.find(|i| i["id"] == json!(id.0))
.cloned()
.unwrap();
assert_eq!(
(&item["columnRule"], &item["columnRuleWeight"], &item["columnRuleColor"]),
(&json!(true), &json!(0.5), &json!("C=100 M=0 Y=0 K=0"))
);
assert_eq!(
(&item["columnRuleTint"], &item["columnRuleOffset"], &item["columnRuleTopInset"], &item["columnRuleBottomInset"]),
(&json!(0.5), &json!(-2.0), &json!(4.0), &json!(6.0))
);
// One undo step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(opts(&s), before);
}
/// A column count past `u32::MAX` was cast to `u32` before the clamp, so
/// 2^32 + 1 wrapped to 1 column instead of clamping to the maximum, 40.
#[test]
fn huge_column_count_clamps_to_the_maximum() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let columns = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.columns;
for (given, want) in [(4_294_967_297_u64, 40), (u64::MAX, 40), (0, 1)] {
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": given})).unwrap();
assert_eq!(columns(&s), want, "columns {given}");
}
}
}
#[cfg(test)]
mod blend_mode_tests {
use designcraft_color::BlendMode;
use super::*;
/// `object.opacity` documents `blend?: normal|multiply|…`, but deserializing the value
/// against the enum only accepted the PascalCase variant names, so the documented
/// lowercase forms were silently dropped: the command reported `changed: 1` while the
/// stored blend stayed as it was (#260).
#[test]
fn lowercase_documented_blend_modes_apply() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let blend = |s: &Session| s.doc().unwrap().doc.item(id).map(|i| i.blend);
for (given, want) in [
("multiply", BlendMode::Multiply),
("screen", BlendMode::Screen),
("MULTIPLY", BlendMode::Multiply),
("Color Burn", BlendMode::ColorBurn),
("color-burn", BlendMode::ColorBurn),
("soft_light", BlendMode::SoftLight),
("normal", BlendMode::Normal),
] {
let out = s.execute("object.opacity", &json!({"opacity": 1, "blend": given})).unwrap();
assert_eq!(out["changed"], 1, "{given}");
assert_eq!(blend(&s), Some(want), "blend mode {given:?} must be applied");
}
}
/// The PascalCase names keep working, so the Properties panel's own submissions are
/// unaffected.
#[test]
fn pascal_case_blend_modes_still_apply() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
s.execute("object.opacity", &json!({"opacity": 0.5, "blend": "Multiply"})).unwrap();
let it = s.doc().unwrap().doc.item(id).cloned().unwrap();
assert_eq!(it.blend, BlendMode::Multiply);
assert!((it.opacity - 0.5).abs() < 1e-6, "opacity must still be set");
}
/// A blend mode the command doesn't know is an error, not a silent success that leaves
/// the previous appearance in place.
#[test]
fn an_unknown_blend_mode_is_an_error() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
s.execute("object.opacity", &json!({"opacity": 1, "blend": "Multiply"})).unwrap();
let e = s.execute("object.opacity", &json!({"opacity": 1, "blend": "sparkle"})).unwrap_err().to_string();
assert!(e.contains("sparkle") && e.contains("blend"), "{e}");
let it = s.doc().unwrap().doc.item(id).cloned().unwrap();
assert_eq!(it.blend, BlendMode::Multiply, "a rejected value changes nothing");
assert_eq!(it.opacity, 1.0);
}
}
#[cfg(test)]
mod reorder_tests {
use super::*;
fn frame(s: &mut Session) -> u64 {
s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap()
}
/// Top-level objects of the first spread on `layer`, topmost first.
fn stack(s: &Session, layer: u64) -> Vec<u64> {
let d = &s.doc().unwrap().doc;
d.spreads[0].items.iter().rev().filter(|i| i.layer.0 == layer).map(|i| i.id.0).collect()
}
#[test]
fn reorder_within_and_across_layers_validates_and_undoes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let l1 = s.doc().unwrap().doc.layers[0].id.0;
let l2 = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap();
s.execute("layer.activate", &json!({"id": l1})).unwrap();
let (a, b, c) = (frame(&mut s), frame(&mut s), frame(&mut s));
s.execute("layer.activate", &json!({"id": l2})).unwrap();
let (x, y) = (frame(&mut s), frame(&mut s));
assert_eq!(stack(&s, l1), [c, b, a]);
s.execute("object.reorder", &json!({"ids": [a], "above": c})).unwrap();
assert_eq!(stack(&s, l1), [a, c, b]);
s.execute("object.reorder", &json!({"ids": [b], "index": 0})).unwrap();
assert_eq!(stack(&s, l1), [b, a, c]);
// To another layer at a position.
s.execute("object.reorder", &json!({"ids": [a], "layer": l2, "index": 1})).unwrap();
assert_eq!((stack(&s, l1), stack(&s, l2)), (vec![b, c], vec![y, a, x]));
s.execute("object.reorder", &json!({"ids": [x, y], "below": c})).unwrap();
assert_eq!((stack(&s, l1), stack(&s, l2)), (vec![b, c, y, x], vec![a]));
// Bad input is an error and changes nothing.
let before = s.doc().unwrap().doc.clone();
for bad in [
json!({"ids": [999], "index": 0}),
json!({"ids": [b], "index": 4}),
json!({"ids": [b], "above": b}),
json!({"ids": [b], "above": 999}),
json!({"ids": [b], "layer": 999}),
json!({"ids": [b], "layer": l2, "above": c}),
json!({"ids": [b], "above": c, "index": 0}),
json!({"ids": [b], "index": "top"}),
json!({"ids": []}),
] {
assert!(s.execute("object.reorder", &bad).is_err(), "{bad}");
assert_eq!(*s.doc().unwrap().doc, *before, "{bad}");
}
// The bottom of a layer is index = its count.
s.execute("object.reorder", &json!({"ids": [b], "index": 3})).unwrap();
assert_eq!(stack(&s, l1), [c, y, x, b]);
// A reorder that changes nothing adds no undo step.
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let steps = undo(&s);
s.execute("object.reorder", &json!({"ids": [x], "index": 2})).unwrap();
s.execute("object.reorder", &json!({"ids": [y], "above": x})).unwrap();
s.execute("object.reorder", &json!({"ids": [c], "layer": l1, "index": 0})).unwrap();
assert_eq!(stack(&s, l1), [c, y, x, b]);
assert_eq!(undo(&s), steps);
// One undo step per reorder.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(stack(&s, l1), [b, c, y, x]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((stack(&s, l1), stack(&s, l2)), (vec![b, c], vec![y, a, x]));
}
#[test]
fn group_members_reorder_only_within_their_group() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let (a, b, c) = (frame(&mut s), frame(&mut s), frame(&mut s));
let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
let kids = |s: &Session| s.doc().unwrap().doc.item(ItemId(g)).unwrap().children().iter().map(|i| i.id.0).collect::<Vec<_>>();
assert_eq!(kids(&s), [a, b]);
s.execute("object.reorder", &json!({"ids": [a], "above": b})).unwrap();
assert_eq!(kids(&s), [b, a]);
s.execute("object.reorder", &json!({"ids": [a], "index": 1})).unwrap();
assert_eq!(kids(&s), [a, b]);
let before = s.doc().unwrap().doc.clone();
assert!(s.execute("object.reorder", &json!({"ids": [a], "above": c})).is_err(), "out of the group");
assert!(s.execute("object.reorder", &json!({"ids": [c], "above": a})).is_err(), "into the group");
assert!(s.execute("object.reorder", &json!({"ids": [a, c], "index": 0})).is_err(), "mixed parents");
assert_eq!(*s.doc().unwrap().doc, *before);
}
#[test]
fn moving_a_group_member_to_a_layer_moves_its_group() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let (a, b) = (frame(&mut s), frame(&mut s));
let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
let art = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("object.setLayer", &json!({"ids": [a, b], "layer": art})).unwrap();
assert_eq!(r["changed"], 1, "the group, once");
let d = &s.doc().unwrap().doc;
assert!([g, a, b].iter().all(|i| d.item(ItemId(*i)).unwrap().layer.0 == art));
assert!(s.execute("object.setLayer", &json!({"ids": [a], "layer": 999})).is_err());
}
}
#[cfg(test)]
mod content_move_tests {
use super::*;
/// A session with a 40×20 image placed in a frame at (100, 100)–(300, 300).
fn placed() -> (Session, ItemId) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
s.execute("place.load", &json!({"base64": super::super::base64_encode(&png)})).unwrap();
s.execute("place.drop", &json!({"frame": f})).unwrap();
(s, ItemId(f))
}
/// Spread bounds of the frame and of its graphic.
fn boxes(s: &Session, id: ItemId) -> (Rect, Rect) {
let it = s.doc().unwrap().doc.item(id).unwrap().clone();
let g = it.graphic().unwrap();
(it.bounds(), (it.xf * g.xf).transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1)))
}
fn near(a: Rect, b: Rect) -> bool {
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6)
}
#[test]
fn content_moves_inside_the_frame_in_one_undo_step() {
let (mut s, f) = placed();
let (frame0, g0) = boxes(&s, f);
s.execute("selection.set", &json!({"ids": [f.0], "content": true})).unwrap();
s.execute("transform.move", &json!({"dx": 10, "dy": -5})).unwrap();
let (frame1, g1) = boxes(&s, f);
assert!(near(frame1, frame0), "the frame stays");
assert!(near(g1, g0 + Vec2::new(10.0, -5.0)), "{g1:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(boxes(&s, f).1, g0));
// Explicit ids with `content` act on the content whatever the selection is.
s.execute("selection.set", &json!({"ids": [f.0]})).unwrap();
s.execute("transform.move", &json!({"ids": [f.0], "content": true, "dx": 3})).unwrap();
assert!(near(boxes(&s, f).0, frame0) && near(boxes(&s, f).1, g0 + Vec2::new(3.0, 0.0)));
// A frame selection moves frame and content together.
s.execute("transform.move", &json!({"dx": 4})).unwrap();
assert!(near(boxes(&s, f).0, frame0 + Vec2::new(4.0, 0.0)) && near(boxes(&s, f).1, g0 + Vec2::new(7.0, 0.0)));
}
#[test]
fn content_moves_on_the_page_axes_in_a_rotated_frame_and_pasted_items_move_too() {
let (mut s, f) = placed();
s.execute("transform.rotate", &json!({"ids": [f.0], "angle": 90})).unwrap();
let (frame0, g0) = boxes(&s, f);
s.execute("transform.move", &json!({"ids": [f.0], "content": true, "dx": 10})).unwrap();
let (frame1, g1) = boxes(&s, f);
assert!(near(frame1, frame0) && near(g1, g0 + Vec2::new(10.0, 0.0)), "{g0:?} → {g1:?}");
// Items pasted into a frame are its content.
let a = s.execute("frame.create", &json!({"rect": [400, 100, 420, 120], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let b = s.execute("frame.create", &json!({"rect": [400, 300, 500, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [b]})).unwrap();
s.execute("edit.pasteInto", &json!({})).unwrap();
let kids = |s: &Session| {
let it = s.doc().unwrap().doc.item(ItemId(b)).unwrap().clone();
(it.bounds(), it.children().iter().map(|c| it.xf.transform_rect_bbox(c.bounds())).collect::<Vec<_>>())
};
let (fb0, k0) = kids(&s);
s.execute("transform.move", &json!({"ids": [b], "content": true, "dx": 5, "dy": 6})).unwrap();
let (fb1, k1) = kids(&s);
assert!(near(fb1, fb0) && near(k1[0], k0[0] + Vec2::new(5.0, 6.0)), "{k0:?} → {k1:?}");
}
}
#[cfg(test)]
mod group_tests {
use super::*;
fn frame(s: &mut Session, rect: [f64; 4], spread: usize) -> ItemId {
ItemId(s.execute("frame.create", &json!({"rect": rect, "spread": spread})).unwrap()["id"].as_u64().unwrap())
}
fn group_ids(s: &mut Session, ids: &[ItemId]) -> Result<ItemId> {
let r = s.execute("object.group", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))?;
Ok(ItemId(r["id"].as_u64().unwrap()))
}
fn children(s: &Session, id: ItemId) -> Vec<ItemId> {
s.doc().unwrap().doc.item(id).unwrap().children().iter().map(|c| c.id).collect()
}
fn world(s: &Session, id: ItemId) -> Affine {
let d = &s.doc().unwrap().doc;
let loc = d.find(id).unwrap();
d.parent_xf(&loc) * d.item(id).unwrap().xf
}
fn group_enabled(s: &Session) -> bool {
super::super::find_command("object.group").unwrap().info(s).enabled
}
#[test]
fn grouping_objects_already_in_a_group_does_not_panic() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let a = frame(&mut s, [36.0, 36.0, 136.0, 136.0], 0);
let b = frame(&mut s, [200.0, 36.0, 300.0, 136.0], 0);
let g = group_ids(&mut s, &[a, b]).unwrap();
let inner = group_ids(&mut s, &[a, b]).unwrap();
assert_eq!(children(&s, g), vec![inner]);
assert_eq!(children(&s, inner), vec![a, b]);
// The same through the selection, and through Convert to Multi-State Object.
s.execute("selection.set", &json!({"ids": [a.0, b.0], "content": true})).unwrap();
assert!(group_enabled(&s));
let inner2 = ItemId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
assert_eq!(children(&s, inner), vec![inner2]);
let st = ItemId(s.execute("states.create", &json!({"ids": [a.0, b.0]})).unwrap()["id"].as_u64().unwrap());
assert_eq!(children(&s, inner2), vec![st]);
assert_eq!(s.doc().unwrap().doc.item(st).unwrap().states.len(), 2);
}
#[test]
fn grouping_siblings_inside_a_group_nests_them_and_undo_restores() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let a = frame(&mut s, [0.0, 0.0, 10.0, 10.0], 0);
let b = frame(&mut s, [20.0, 0.0, 30.0, 10.0], 0);
let c = frame(&mut s, [40.0, 0.0, 50.0, 10.0], 0);
let g = group_ids(&mut s, &[a, b, c]).unwrap();
s.execute("transform.rotate", &json!({"ids": [g.0], "angle": 30})).unwrap();
let before = (world(&s, a), world(&s, c));
let inner = group_ids(&mut s, &[c, a]).unwrap();
assert_eq!(children(&s, g), vec![b, inner], "the new group takes the frontmost object's place");
assert_eq!(children(&s, inner), vec![a, c], "stacking order kept");
assert_eq!((world(&s, a), world(&s, c)), before, "nothing moves");
assert_eq!(s.doc().unwrap().selection.items, vec![inner]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(children(&s, g), vec![a, b, c]);
assert!(s.doc().unwrap().doc.find(inner).is_none());
}
#[test]
fn grouping_across_levels_or_spreads_is_refused_and_changes_nothing() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
let a = frame(&mut s, [0.0, 0.0, 10.0, 10.0], 0);
let b = frame(&mut s, [20.0, 0.0, 30.0, 10.0], 0);
let top = frame(&mut s, [40.0, 0.0, 50.0, 10.0], 0);
let other = frame(&mut s, [0.0, 0.0, 10.0, 10.0], 1);
let other2 = frame(&mut s, [20.0, 0.0, 30.0, 10.0], 1);
let g = group_ids(&mut s, &[a, b]).unwrap();
let doc = s.doc().unwrap().doc.clone();
for ids in [[a, top], [top, a], [a, other], [top, other], [g, a]] {
let e = group_ids(&mut s, &ids).unwrap_err().to_string();
assert!(e.contains("same spread and at the same level"), "{ids:?}: {e}");
let e = s.execute("states.create", &json!({"ids": [ids[0].0, ids[1].0]})).unwrap_err().to_string();
assert!(e.contains("same spread and at the same level"), "states.create {ids:?}: {e}");
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &doc), "{ids:?} changed the document");
}
assert!(group_ids(&mut s, &[a, ItemId(999_999)]).is_err());
s.execute("selection.set", &json!({"ids": [a.0, other.0], "content": true})).unwrap();
assert!(!group_enabled(&s), "Group is disabled for objects on different spreads");
s.execute("selection.set", &json!({"ids": [other.0, other2.0]})).unwrap();
assert!(group_enabled(&s));
// Named objects win over a selection that can't be grouped.
s.execute("selection.set", &json!({"ids": [a.0, other.0], "content": true})).unwrap();
assert!(group_ids(&mut s, &[other, other2]).is_ok());
// A multi-state object keeps one state per object.
let ms = ItemId(s.execute("states.create", &json!({"ids": [g.0]})).unwrap()["id"].as_u64().unwrap());
assert_eq!(ms, g);
let e = group_ids(&mut s, &[a, b]).unwrap_err().to_string();
assert!(e.contains("multi-state"), "{e}");
assert_eq!(children(&s, g), vec![a, b]);
}
}
#[cfg(test)]
mod stroke_type_tests {
use serde_json::json;
#[test]
fn malformed_stroke_types_return_bad_params_without_editing() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].clone();
for (kind, hint) in [
(json!({"kind": "dashed", "dash": [8, 2.5]}), "dash"),
(json!({"kind": "zigzag"}), "zigzag"),
(json!({"kind": "solid", "dash": [8, 2.5]}), "dash"),
(json!({"kind": "dashed", "pattern": [8, 2.5], "dash": [1, 2]}), "dash"),
(json!({"kind": "dashed"}), "pattern"),
(json!({"kind": "dashed", "pattern": "bad"}), "type"),
(json!({"kind": "stripes", "bands": [[0, "bad"]]}), "type"),
(json!({"kind": "style"}), "name"),
(json!({}), "kind"),
(json!("solid"), "type"),
(json!(null), "type"),
] {
let before = s.doc().unwrap().doc.clone();
let error = s.execute("object.stroke", &json!({"ids": [id], "weight": 5, "type": kind})).unwrap_err();
assert!(matches!(error, crate::EngineError::BadParams { .. }), "{error}");
assert!(error.to_string().contains(hint), "{kind}: {error}");
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{kind}: nothing changed");
}
}
#[test]
fn stroke_types_apply_all_supported_variants() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].as_u64().unwrap();
for kind in [
json!({"kind": "solid"}),
json!({"kind": "dashed", "pattern": [8, 2.5]}),
json!({"kind": "dotted"}),
json!({"kind": "thickThin"}),
json!({"kind": "thinThick"}),
json!({"kind": "thinThin"}),
json!({"kind": "thickThick"}),
json!({"kind": "thinThickThin"}),
json!({"kind": "thickThinThick"}),
json!({"kind": "wavy"}),
json!({"kind": "hashed"}),
json!({"kind": "stripes", "bands": [[0, 0.3], [0.7, 0.3]]}),
json!({"kind": "style", "name": "Rails"}),
] {
let result = s.execute("object.stroke", &json!({"ids": [id], "type": kind})).unwrap();
assert_eq!(result["changed"], 1);
let item = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap();
assert_eq!(item.stroke.kind, serde_json::from_value::<designcraft_doc::StrokeType>(kind).unwrap());
}
}
}
#[cfg(test)]
mod swatch_name_tests {
use serde_json::json;
#[test]
fn fills_and_strokes_must_name_a_swatch_and_change_nothing_otherwise() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].clone();
for (cmd, params, hint) in [
("object.fill", json!({"swatch": "Paper", "ids": [id]}), Some("[Paper]")),
("object.fill", json!({"swatch": "[black]", "ids": [id]}), Some("[Black]")),
("object.fill", json!({"swatch": "Missing Ink", "ids": [id]}), None),
("object.stroke", json!({"swatch": "#ff0000", "weight": 4, "ids": [id]}), None),
("object.stroke", json!({"gapSwatch": "paper", "ids": [id]}), Some("[Paper]")),
] {
let before = s.doc().unwrap().doc.clone();
let e = s.execute(cmd, &params).unwrap_err().to_string();
assert!(e.contains("no swatch named") && e.contains("swatch.create"), "{cmd}: {e}");
if let Some(h) = hint {
assert!(e.contains(&format!("did you mean `{h}`")), "{cmd}: {e}");
}
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{cmd}: nothing changed");
}
// Real names, [None] included, still work.
for sw in ["[Paper]", "[None]", "C=100 M=0 Y=0 K=0"] {
s.execute("object.fill", &json!({"swatch": sw, "ids": [id]})).unwrap();
s.execute("object.stroke", &json!({"swatch": sw, "ids": [id]})).unwrap();
}
}
}
#[cfg(test)]
mod copy_ids_tests {
use serde_json::json;
#[test]
fn copies_report_their_ids() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let ids = |r: &serde_json::Value| r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect::<Vec<_>>();
let selected = |s: &crate::Session| s.doc().unwrap().selection.items.iter().map(|i| i.0).collect::<Vec<_>>();
// Moving reports what moved; copying reports the copies, which are selected.
assert_eq!(ids(&s.execute("transform.move", &json!({"dx": 5, "dy": 0, "ids": [a]})).unwrap()), [a]);
assert_eq!(ids(&s.execute("transform.move", &json!({"dx": 5, "dy": 0, "ids": [a], "content": true})).unwrap()), [a]);
let r = s.execute("transform.move", &json!({"dx": 100, "dy": 0, "copy": true, "ids": [a]})).unwrap();
let copy = ids(&r);
assert!(copy.len() == 1 && copy[0] != a && s.doc().unwrap().doc.item(designcraft_doc::ItemId(copy[0])).is_some(), "{r}");
assert_eq!(selected(&s), copy);
// Edit › Duplicate.
let r = s.execute("edit.duplicate", &json!({})).unwrap();
let dup = ids(&r);
assert!(dup.len() == 1 && dup[0] != copy[0], "{r}");
assert_eq!(selected(&s), dup);
// Step and Repeat: every copy.
let r = s.execute("edit.stepAndRepeat", &json!({"count": 3, "dx": 0, "dy": 60, "ids": [a]})).unwrap();
assert_eq!((r["created"].as_u64(), ids(&r).len()), (Some(3), 3), "{r}");
assert!(ids(&r).iter().all(|i| s.doc().unwrap().doc.item(designcraft_doc::ItemId(*i)).is_some()));
}
}
#[cfg(test)]
mod tint_alone_tests {
use serde_json::json;
#[test]
fn a_tint_alone_keeps_each_swatch_and_leaves_no_stroke_alone() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [60, 0, 110, 50]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [a]})).unwrap();
s.execute("object.fill", &json!({"swatch": "C=100 M=0 Y=0 K=0", "ids": [b]})).unwrap();
s.execute("object.stroke", &json!({"swatch": "[None]", "ids": [a, b]})).unwrap();
// Fill tint on both: each keeps its swatch.
s.execute("object.fill", &json!({"tint": 0.25, "ids": [a, b]})).unwrap();
let item = |s: &crate::Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().clone();
assert_eq!((item(&s, a).fill.swatch.as_str(), item(&s, a).fill.tint), ("[Black]", 0.25));
assert_eq!((item(&s, b).fill.swatch.as_str(), item(&s, b).fill.tint), ("C=100 M=0 Y=0 K=0", 0.25));
// A swatch alone is 100%.
s.execute("object.fill", &json!({"swatch": "[Paper]", "ids": [a]})).unwrap();
assert_eq!(item(&s, a).fill.tint, 1.0);
// A stroke tint on no stroke: still no stroke.
s.execute("object.stroke", &json!({"tint": 0.5, "ids": [a]})).unwrap();
assert!(item(&s, a).stroke.is_none(), "{:?}", item(&s, a).stroke);
assert!(s.execute("object.fill", &json!({"ids": [a]})).is_err(), "neither swatch nor tint");
}
}
#[cfg(test)]
mod content_transform_tests {
use serde_json::json;
use crate::Session;
/// A rotated graphic frame holding a 200 x 100 image, its content selected.
fn rotated_frame_with_image() -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
let png = designcraft_render::Rendered { width: 200, height: 100, pixels: vec![200u8; 200 * 100 * 4] }.to_png();
s.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&png), "name": "a.png", "frame": f})).unwrap();
s.execute("transform.rotate", &json!({"ids": [f], "angle": -45})).unwrap();
s.execute("selection.set", &json!({"ids": [f], "content": true})).unwrap();
(s, f)
}
#[test]
fn content_bounds_resize_the_graphic_and_leave_the_frame() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
let png = designcraft_render::Rendered { width: 200, height: 100, pixels: vec![200u8; 200 * 100 * 4] }.to_png();
s.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&png), "name": "a.png", "frame": f})).unwrap();
let bounds = |s: &Session| {
let it = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().clone();
let g = it.graphic().unwrap().clone();
(it.bounds(), (it.xf * g.xf).transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)))
};
let (frame0, _) = bounds(&s);
// The handles' drag: the image to (20, 40)–(420, 240), one undo step; the frame stays.
s.execute("transform.set", &json!({"ids": [f], "content": true, "bounds": [20, 40, 420, 240]})).unwrap();
let (frame1, g1) = bounds(&s);
assert_eq!(frame1, frame0);
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
assert!(near(g1.x0, 20.0) && near(g1.y0, 40.0) && near(g1.x1, 420.0) && near(g1.y1, 240.0), "{g1:?}");
// Dragged past the opposite edge: normalized, never empty.
s.execute("transform.set", &json!({"ids": [f], "content": true, "bounds": [300, 300, 300, 100]})).unwrap();
let (_, g2) = bounds(&s);
assert!(g2.width() > 0.0 && near(g2.y0, 100.0) && near(g2.y1, 300.0), "{g2:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(bounds(&s).1.x1, 420.0));
}
#[test]
fn rotating_the_content_leaves_the_frame_as_it_is() {
let (mut s, f) = rotated_frame_with_image();
let before = s.execute("transform.info", &json!({"ids": [f]})).unwrap();
assert!((before["rotation"].as_f64().unwrap() + 45.0).abs() < 1e-6 && (before["content"]["rotation"].as_f64().unwrap() + 45.0).abs() < 1e-6);
// The tutorial's step: the image straightened inside its tilted frame.
s.execute("transform.set", &json!({"rotation": 0})).unwrap();
let after = s.execute("transform.info", &json!({"ids": [f]})).unwrap();
assert!((after["rotation"].as_f64().unwrap() + 45.0).abs() < 1e-6, "the frame stays at -45: {after}");
assert!(after["content"]["rotation"].as_f64().unwrap().abs() < 1e-6, "the image is upright: {after}");
// Scale and position act on the image too.
s.execute("transform.set", &json!({"scaleX": 50, "scaleY": 50})).unwrap();
let info = s.execute("transform.info", &json!({"ids": [f]})).unwrap();
assert!((info["content"]["scaleX"].as_f64().unwrap() - 50.0).abs() < 1e-6, "{info}");
assert!((info["rotation"].as_f64().unwrap() + 45.0).abs() < 1e-6);
// One undo step each.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let undone = s.execute("transform.info", &json!({"ids": [f]})).unwrap();
assert!((undone["content"]["rotation"].as_f64().unwrap() + 45.0).abs() < 1e-6, "{undone}");
}
#[test]
fn with_the_graphic_selected_transform_info_reports_the_graphic() {
let (mut s, f) = rotated_frame_with_image();
s.execute("transform.set", &json!({"rotation": 0})).unwrap();
// What the Control bar and Properties read: the graphic's 0, not the frame's -45, so a
// spinner step from it gives 1, not -44.
let info = s.execute("transform.info", &json!({})).unwrap();
assert!(info["rotation"].as_f64().unwrap().abs() < 1e-6, "{info}");
let rot = info["rotation"].as_f64().unwrap() + 1.0;
s.execute("transform.set", &json!({"rotation": rot})).unwrap();
let after = s.execute("transform.info", &json!({"ids": [f]})).unwrap();
assert!((after["content"]["rotation"].as_f64().unwrap() - 1.0).abs() < 1e-6, "{after}");
assert!((after["rotation"].as_f64().unwrap() + 45.0).abs() < 1e-6, "the frame stays: {after}");
// `id` names the target explicitly, like `ids`: the frame.
let by_id = s.execute("transform.info", &json!({"id": f})).unwrap();
assert!((by_id["rotation"].as_f64().unwrap() + 45.0).abs() < 1e-6, "{by_id}");
}
#[test]
fn the_graphic_moves_to_x_and_y_from_the_rulers_zero_point() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
let png = designcraft_render::Rendered { width: 200, height: 100, pixels: vec![200u8; 200 * 100 * 4] }.to_png();
s.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&png), "name": "a.png", "frame": f})).unwrap();
s.execute("view.zeroPoint", &json!({"at": [72, 36]})).unwrap();
s.execute("selection.set", &json!({"ids": [f], "content": true})).unwrap();
s.execute("transform.set", &json!({"x": 10, "y": 20, "ref": 0})).unwrap();
// The graphic's top-left, measured from the zero point (what the Transform fields show).
let d = &s.doc().unwrap().doc;
let id = designcraft_doc::ItemId(f);
let loc = d.find(id).unwrap();
let it = d.item(id).unwrap();
let g = it.graphic().unwrap();
let b = (d.parent_xf(&loc) * it.xf * g.xf).transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1));
let o = d.ruler_origin(loc.spread, b.center().x).unwrap();
assert!(o.x > 0.0 && o.y > 0.0, "the zero point moved: {o:?}");
assert!((b.x0 - o.x - 10.0).abs() < 1e-6 && (b.y0 - o.y - 20.0).abs() < 1e-6, "{b:?} from {o:?}");
// The frame stays.
assert!((it.bounds().x0 - 100.0).abs() < 1e-6, "{:?}", it.bounds());
}
#[test]
fn a_locked_frame_records_no_undo_step() {
let (mut s, f) = rotated_frame_with_image();
s.execute("object.lock", &json!({"ids": [f]})).unwrap();
s.execute("selection.set", &json!({"ids": [f], "content": true})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("transform.set", &json!({"rotation": 0})).unwrap()["changed"], 0);
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no empty undo step");
}
#[test]
fn a_frame_selection_still_transforms_the_frame_and_an_empty_frame_has_no_content() {
let (mut s, f) = rotated_frame_with_image();
// `content: false` (or a frame selection) turns frame and image together.
s.execute("transform.set", &json!({"rotation": 0, "content": false})).unwrap();
let info = s.execute("transform.info", &json!({"ids": [f]})).unwrap();
assert!(info["rotation"].as_f64().unwrap().abs() < 1e-6, "{info}");
let e = s.execute("frame.create", &json!({"rect": [400, 100, 500, 200], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
assert!(s.execute("transform.set", &json!({"ids": [e], "content": true, "rotation": 10})).is_err());
}
}
#[cfg(test)]
mod fit_text_frame_tests {
use super::*;
fn frame(s: &mut Session, rect: [f64; 4], text: &str) -> ItemId {
let id = ItemId(s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text})).unwrap()["id"].as_u64().unwrap());
s.execute("selection.set", &json!({"ids": [id.0]})).unwrap();
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
id
}
fn bounds(s: &Session, id: ItemId) -> Rect {
s.doc().unwrap().doc.item(id).unwrap().bounds()
}
#[test]
fn fit_frame_to_content_fits_a_text_frame_to_its_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = frame(&mut s, [100.0, 100.0, 500.0, 300.0], "INDESIGN");
// The height only (a top or bottom handle): the width stays.
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "height", "ids": [id.0]})).unwrap();
let h = bounds(&s, id);
assert!(h.x1 == 500.0 && h.y0 == 100.0 && h.height() > 30.0 && h.height() < 45.0, "{h:?}");
// Both (a corner handle, or the menu): around the line.
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "ids": [id.0]})).unwrap();
let b = bounds(&s, id);
assert!(b.x0 == 100.0 && b.width() > 100.0 && b.width() < 250.0, "{b:?}");
let d = s.doc().unwrap().doc.clone();
let sid = d.item(id).unwrap().text_frame().unwrap().story;
let cs = s.cache.get(&d, sid, None);
assert!(!cs.is_overset(), "still fits");
// The bottom is on the baseline, so a frame scaled up with its text still fits.
let baseline = cs.frame(id).unwrap().lines[0].baseline;
assert!((b.y1 - baseline).abs() < 1e-9, "{b:?} {baseline}");
}
#[test]
fn fit_frame_to_content_grows_a_frame_with_overset_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = frame(&mut s, [100.0, 100.0, 300.0, 140.0], "one two three four five six seven eight nine ten eleven twelve");
let d = s.doc().unwrap().doc.clone();
let sid = d.item(id).unwrap().text_frame().unwrap().story;
assert!(s.cache.get(&d, sid, None).is_overset());
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "height", "ids": [id.0]})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert!(!s.cache.get(&d, sid, None).is_overset(), "{:?}", bounds(&s, id));
assert!(bounds(&s, id).height() > 100.0);
assert!(s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "diagonal", "ids": [id.0]})).is_err());
}
#[test]
fn fitting_text_keeps_columns_and_vertical_justification() {
use designcraft_doc::VerticalJustification::{Bottom, Center, Justify, Top};
for columns in [1, 2] {
for justification in [Top, Bottom, Center, Justify] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = frame(&mut s, [100.0, 100.0, 500.0, 500.0], "one two three four five six seven eight nine ten eleven twelve");
let options = &mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(id).unwrap().text_frame_mut().unwrap().options;
options.columns = columns;
options.vertical_justification = justification;
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "height"})).unwrap();
let d = &s.doc().unwrap().doc;
let it = d.item(id).unwrap();
let tf = it.text_frame().unwrap();
let composed = s.cache.get(d, tf.story, None);
assert!(!composed.is_overset(), "{columns} {justification:?}");
assert!(it.bounds().height() < 250.0, "{columns} {justification:?}: {:?}", it.bounds());
assert_eq!(tf.options.vertical_justification, justification);
assert_eq!(tf.options.columns, columns);
if columns == 2 {
let ft = composed.frame(id).unwrap();
assert!(ft.lines.iter().any(|l| l.column == 1), "both columns stay in use: {ft:?}");
}
}
}
}
#[test]
fn graphic_fitting_obeys_each_axis_and_locked_text_and_graphics_stay_put() {
for graphic in [false, true] {
for lock_layer in [false, true] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = frame(&mut s, [100.0, 100.0, 500.0, 300.0], "Fit me");
if graphic {
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "test.png", "frame": id.0})).unwrap();
let it = Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(id).unwrap();
let Content::Graphic(g) = &mut it.content else { panic!("graphic") };
g.xf = Affine::translate((80.0, 90.0)) * Affine::scale(3.0);
let original = it.inner_bounds();
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "width", "ids": [id.0]})).unwrap();
let fitted = s.doc().unwrap().doc.item(id).unwrap().inner_bounds();
assert_eq!(fitted, Rect::new(80.0, original.y0, 200.0, original.y1));
Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(id).unwrap().path = shapes::rectangle(original);
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "height", "ids": [id.0]})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(id).unwrap().inner_bounds(), Rect::new(original.x0, 90.0, original.x1, 150.0));
Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(id).unwrap().path = shapes::rectangle(original);
}
let before = bounds(&s, id);
if lock_layer {
let layer = s.doc().unwrap().doc.item(id).unwrap().layer;
Arc::make_mut(&mut s.doc_mut().unwrap().doc).layer_mut(layer).unwrap().locked = true;
} else {
Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(id).unwrap().locked = true;
}
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "ids": [id.0]})).unwrap();
assert_eq!(bounds(&s, id), before, "graphic={graphic}, layer={lock_layer}");
}
}
}
#[test]
fn an_unfittable_frame_returns_an_error_without_changing_any_target() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = frame(&mut s, [100.0, 100.0, 500.0, 300.0], "Fits");
let b = frame(&mut s, [100.0, 400.0, 500.0, 600.0], "Cannot fit");
Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(b).unwrap().text_frame_mut().unwrap().options.first_baseline_min = 30_000.0;
let before = [bounds(&s, a), bounds(&s, b)];
assert!(s.execute("object.fit", &json!({"mode": "fitFrameToContent", "ids": [a.0, b.0]})).is_err());
assert_eq!([bounds(&s, a), bounds(&s, b)], before);
}
}
#[cfg(test)]
mod spread_target_tests {
use super::*;
#[test]
fn creating_on_a_parent_spread_and_rejecting_unknown_spreads() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let parent = json!({"kind": "parent", "index": 0});
let r = s.execute("frame.create", &json!({"spread": parent, "rect": [0, 0, 100, 100]})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
assert_eq!(s.doc().unwrap().doc.find(id).unwrap().spread, SpreadRef::Parent(0));
s.execute("line.create", &json!({"spread": parent, "a": [0, 0], "b": [50, 50]})).unwrap();
assert_eq!(s.doc().unwrap().doc.parents[0].items.len(), 2);
// Each of these used to land on the first body page.
for bad in
[json!("A-Parent"), json!("0"), json!(-1), json!(1.5), json!({"kind": "master", "index": 0}), json!({"kind": "parent"}), json!([0])]
{
assert!(s.execute("frame.create", &json!({"spread": bad, "rect": [0, 0, 100, 100]})).is_err(), "frame.create spread {bad}");
assert!(s.execute("line.create", &json!({"spread": bad, "a": [0, 0], "b": [50, 50]})).is_err(), "line.create spread {bad}");
}
assert!(s.doc().unwrap().doc.spreads[0].items.is_empty());
// Null is absent: spread 0 (Paste in Place forwards its optional `spread`).
let r = s.execute("frame.create", &json!({"spread": null, "rect": [0, 0, 100, 100]})).unwrap();
assert_eq!(s.doc().unwrap().doc.find(ItemId(r["id"].as_u64().unwrap())).unwrap().spread, SpreadRef::Doc(0));
s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.pasteInPlace", &json!({})).unwrap();
}
}
#[cfg(test)]
mod path_edit_tests {
use designcraft_doc::ItemId;
use designcraft_geom::Point;
use serde_json::json;
fn session_with_path() -> (crate::Session, u64) {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s
.execute(
"path.create",
&json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100], "in": [170, 80], "out": [230, 120]}, {"p": [300, 200]}]}),
)
.unwrap()["id"]
.as_u64()
.unwrap();
(s, id)
}
fn anchors(s: &crate::Session, id: u64) -> Vec<designcraft_geom::Anchor> {
let d = &s.doc().unwrap().doc;
let it = d.item(ItemId(id)).unwrap();
let xf = d.parent_xf(&d.find(ItemId(id)).unwrap()) * it.xf;
it.path.subpaths[0].anchors.iter().map(|a| a.transform(xf)).collect()
}
#[test]
fn reverse_path_swaps_ends_and_direction_lines() {
let (mut s, id) = session_with_path();
s.execute("path.reverse", &json!({"ids": [id]})).unwrap();
let a = anchors(&s, id);
assert_eq!(a.iter().map(|a| a.p).collect::<Vec<_>>(), [Point::new(300.0, 200.0), Point::new(200.0, 100.0), Point::new(100.0, 100.0)]);
assert_eq!((a[1].h_in, a[1].h_out), (Point::new(230.0, 120.0), Point::new(170.0, 80.0)));
assert!(s.execute("path.reverse", &json!({"ids": [id], "subpath": 3})).is_err());
}
#[test]
fn anchors_go_on_the_named_open_subpath_and_set_anchor_reshapes_one() {
let (mut s, id) = session_with_path();
s.execute("path.appendAnchor", &json!({"id": id, "subpath": 0, "anchor": {"p": [400, 250]}})).unwrap();
assert_eq!(anchors(&s, id).last().unwrap().p, Point::new(400.0, 250.0));
assert!(s.execute("path.appendAnchor", &json!({"id": id, "subpath": 1, "anchor": {"p": [0, 0]}})).is_err());
// `at: start` goes before the first anchor (the Pen continuing from a path's start).
s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [50, 60]}, "at": "start"})).unwrap();
assert_eq!((anchors(&s, id)[0].p, anchors(&s, id).len()), (Point::new(50.0, 60.0), 5));
assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}, "at": "middle"})).is_err());
// Direction lines on the last anchor: collinear and opposite make it smooth.
s.execute("path.setAnchor", &json!({"id": id, "anchor": "last", "in": [380, 230], "out": [420, 270]})).unwrap();
let last = *anchors(&s, id).last().unwrap();
assert_eq!((last.h_in, last.h_out, last.kind), (Point::new(380.0, 230.0), Point::new(420.0, 270.0), designcraft_geom::AnchorKind::Smooth));
s.execute("path.setAnchor", &json!({"id": id, "anchor": 0, "p": [90, 110]})).unwrap();
assert_eq!(anchors(&s, id)[0].p, Point::new(90.0, 110.0));
assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": 9, "p": [0, 0]})).is_err());
assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": "last"})).is_err(), "nothing to set");
// Closing one subpath; then it takes no more anchors.
s.execute("path.close", &json!({"id": id, "subpath": 0})).unwrap();
assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}})).is_err());
assert!(s.execute("path.close", &json!({"id": id, "subpath": 4})).is_err());
}
/// Every edit that `path_editable` guards, aimed at the path `id` of `session_with_path`.
fn path_edits(id: u64) -> Vec<(&'static str, serde_json::Value)> {
vec![
("path.appendAnchor", json!({"id": id, "anchor": {"p": [400, 250]}})),
("path.setAnchor", json!({"id": id, "anchor": 0, "p": [90, 110]})),
("path.close", json!({"id": id})),
("path.moveAnchors", json!({"id": id, "anchors": [[0, 0]], "dx": 10, "dy": 10})),
("path.reverse", json!({"ids": [id]})),
("path.addAnchor", json!({"id": id, "at": [150, 100]})),
("path.deleteAnchor", json!({"id": id, "subpath": 0, "anchor": 0})),
("path.convertAnchor", json!({"id": id, "subpath": 0, "anchor": 0})),
("path.erase", json!({"id": id, "points": [[120, 100], [180, 100]]})),
("path.smooth", json!({"id": id, "points": [[120, 100], [180, 100]]})),
("path.split", json!({"id": id, "at": [150, 100]})),
("path.join", json!({"ids": [id]})),
("path.convertPoints", json!({"ids": [id], "kind": "corner"})),
]
}
fn assert_no_path_edit(s: &mut crate::Session, id: u64, why: &str) {
let before = anchors(s, id);
for (cmd, p) in path_edits(id) {
assert!(s.execute(cmd, &p).is_err(), "{cmd} {why}");
}
assert_eq!(anchors(s, id), before, "{why}");
assert!(!s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].closed, "{why}");
}
#[test]
fn path_edits_leave_a_path_on_a_locked_layer_alone() {
let (mut s, id) = session_with_path();
let layer = s.doc().unwrap().doc.item(ItemId(id)).unwrap().layer;
s.execute("layer.set", &json!({"id": layer.0, "locked": true})).unwrap();
assert_no_path_edit(&mut s, id, "on a locked layer");
}
#[test]
fn joining_two_paths_with_one_on_a_locked_layer_changes_neither() {
let (mut s, a) = session_with_path();
let b = s.execute("path.create", &json!({"anchors": [{"p": [300, 200]}, {"p": [400, 300]}]})).unwrap()["id"].as_u64().unwrap();
let layer = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap();
s.execute("object.setLayer", &json!({"ids": [b], "layer": layer})).unwrap();
let a_layer = s.doc().unwrap().doc.item(ItemId(a)).unwrap().layer;
s.execute("layer.set", &json!({"id": a_layer.0, "locked": true})).unwrap();
let (before_a, before_b) = (anchors(&s, a), anchors(&s, b));
for ids in [[a, b], [b, a]] {
assert!(s.execute("path.join", &json!({"ids": ids})).is_err(), "{ids:?}");
}
assert_eq!((anchors(&s, a), anchors(&s, b)), (before_a, before_b));
for id in [a, b] {
assert!(!s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].closed, "{id} stays open");
}
}
#[test]
fn path_edits_leave_a_path_in_a_locked_group_alone() {
let (mut s, id) = session_with_path();
let other = s.execute("frame.create", &json!({"rect": [400, 400, 450, 450]})).unwrap()["id"].as_u64().unwrap();
let group = s.execute("object.group", &json!({"ids": [id, other]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.lock", &json!({"ids": [group]})).unwrap();
assert!(!s.doc().unwrap().doc.item(ItemId(id)).unwrap().locked, "only the group is locked");
assert_no_path_edit(&mut s, id, "in a locked group");
}
}
#[cfg(test)]
mod effects_command_tests {
use designcraft_doc::ItemId;
use serde_json::json;
#[test]
fn effects_set_together_in_one_undo_step_and_keep_what_is_not_given() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.dropShadow", &json!({"on": true, "distance": 10, "ids": [id]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = s
.execute(
"object.effects",
&json!({"ids": [id], "opacity": 0.5, "blend": "multiply",
"effects": {"innerShadow": {"on": true, "angle": 120, "distance": 7, "size": 7}, "dropShadow": {"size": 9}, "feather": 4}}),
)
.unwrap();
assert_eq!(r["changed"], 1);
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
let it = st.doc.item(ItemId(id)).unwrap();
let e = &it.effects;
assert!(e.inner_shadow.on && e.inner_shadow.angle == 120.0 && e.inner_shadow.size == 7.0);
assert!(e.drop_shadow.on && e.drop_shadow.distance == 10.0 && e.drop_shadow.size == 9.0, "untouched fields stay");
assert_eq!((e.feather, it.opacity, it.blend), (4.0, 0.5, designcraft_color::BlendMode::Multiply));
// Out of range values are kept in range; garbage changes nothing.
s.execute("object.effects", &json!({"ids": [id], "effects": {"outerGlow": {"on": true, "opacity": 3, "spread": 400, "size": -5}}})).unwrap();
let og = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.outer_glow.clone();
assert_eq!((og.opacity, og.spread, og.size), (1.0, 100.0, 0.0));
let before = s.doc().unwrap().doc.clone();
for bad in [json!({"effects": {"dropShadow": {"distance": "far"}}}), json!({"effects": 5}), json!({"blend": "loud"}), json!({"opacity": "x"})]
{
let mut p = bad.clone();
p["ids"] = json!([id]);
assert!(s.execute("object.effects", &p).is_err(), "{bad}");
}
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc));
}
}
#[cfg(test)]
mod ungroup_tests {
use super::*;
#[test]
fn ungroup_leaves_frames_with_pasted_in_items_whole() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = |r: Value| ItemId(r["id"].as_u64().unwrap());
let sq = id(s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap());
let ell = id(s.execute("frame.create", &json!({"rect": [90, 90, 210, 210], "shape": "ellipse"})).unwrap());
s.execute("selection.set", &json!({"ids": [sq.0]})).unwrap();
s.execute("edit.cut", &json!({})).unwrap();
s.execute("selection.set", &json!({"ids": [ell.0]})).unwrap();
s.execute("edit.pasteInto", &json!({})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert_eq!(d.item(ell).unwrap().default_label(), "<ellipse>", "named by its shape, not <group>");
// Ungroup: the frame keeps its content and stays selected.
s.execute("object.ungroup", &json!({})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert!(d.item(ell).unwrap().has_nested_items());
assert_eq!(d.spreads[0].items.len(), 1);
assert_eq!(s.doc().unwrap().selection.items, vec![ell]);
// A real group with the frame in it still comes apart, the frame whole.
let other = id(s.execute("frame.create", &json!({"rect": [300, 100, 400, 200]})).unwrap());
s.execute("selection.set", &json!({"ids": [ell.0, other.0]})).unwrap();
let g = id(s.execute("object.group", &json!({})).unwrap());
assert_eq!(s.doc().unwrap().doc.item(g).unwrap().default_label(), "<group>");
s.execute("object.ungroup", &json!({})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert!(d.item(g).is_none());
assert!(d.item(ell).unwrap().has_nested_items());
assert_eq!(s.doc().unwrap().selection.items.len(), 2);
d.check().unwrap();
}
}
#[cfg(test)]
mod fixed_width_tests {
use super::*;
#[test]
fn fixed_width_columns_set_the_frame_width() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let p = json!({"ids": [id.0], "columns": 2, "gutter": 12, "inset": 0, "columnsKind": "fixedWidth", "columnWidth": 120});
s.execute("object.textFrameOptions", &p).unwrap();
let b = s.doc().unwrap().doc.item(id).unwrap().inner_bounds();
assert!((b.width() - (2.0 * 120.0 + 12.0)).abs() < 1e-6, "{b:?}");
assert!((b.x0 - 36.0).abs() < 1e-6 && (b.height() - 264.0).abs() < 1e-6, "{b:?}");
// Widths beyond the largest page are refused and change nothing.
for (k, v) in [("columnWidth", 1e9), ("columnWidth", -1.0), ("firstBaselineMin", 1e9)] {
assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], k: v})).is_err(), "{k} {v}");
}
assert!((s.doc().unwrap().doc.item(id).unwrap().inner_bounds().width() - 252.0).abs() < 1e-6);
}
}
#[cfg(test)]
mod glow_feather_options_tests {
use serde_json::json;
#[test]
fn glow_mode_technique_and_feather_options_are_kept() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap();
s.execute(
"object.effects",
&json!({"ids": [id], "effects": {"innerGlow": {"on": true, "mode": "Multiply", "technique": "precise"}, "feather": 4.5, "featherOptions": {"choke": 140, "corners": "sharp"}}}),
)
.unwrap();
let e = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().effects.clone();
assert_eq!((e.inner_glow.mode, e.inner_glow.technique), (designcraft_color::BlendMode::Multiply, designcraft_doc::GlowTechnique::Precise));
assert_eq!((e.feather_options.choke, e.feather_options.corners), (100.0, designcraft_doc::FeatherCorners::Sharp), "choke is a percentage");
// InDesign's defaults: Screen and Softer; Diffused corners, no choke.
let d = designcraft_doc::Effects::default();
assert_eq!((d.inner_glow.mode, d.inner_glow.technique), (designcraft_color::BlendMode::Screen, designcraft_doc::GlowTechnique::Softer));
assert_eq!((d.feather_options.choke, d.feather_options.corners), (0.0, designcraft_doc::FeatherCorners::Diffused));
}
}
#[cfg(test)]
mod fill_tint_tests {
use super::*;
#[test]
fn a_tint_alone_keeps_each_objects_own_swatch() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [136, 36, 200, 100]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.fill", &json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap();
s.execute("object.fill", &json!({"ids": [b], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap();
s.execute("object.fill", &json!({"ids": [a, b], "tint": 0.4})).unwrap();
let fill_of = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().fill.clone();
assert_eq!((fill_of(&s, a).swatch.as_str(), fill_of(&s, a).tint), ("C=100 M=0 Y=0 K=0", 0.4));
assert_eq!((fill_of(&s, b).swatch.as_str(), fill_of(&s, b).tint), ("C=0 M=100 Y=0 K=0", 0.4));
// A swatch without a tint is at 100 %, as before.
s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap();
assert_eq!(fill_of(&s, a).tint, 1.0);
// Neither, or a tint out of range: refused.
assert!(s.execute("object.fill", &json!({"ids": [a]})).is_err());
assert!(s.execute("object.fill", &json!({"ids": [a], "tint": 2})).is_err());
assert!(s.execute("object.fill", &json!({"ids": [a], "tint": "x"})).is_err());
// A stroke tint alone: stroked objects take it, an object without a stroke keeps none.
s.execute("object.stroke", &json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0", "weight": 2})).unwrap();
s.execute("object.stroke", &json!({"ids": [b], "swatch": "[None]"})).unwrap();
s.execute("object.stroke", &json!({"ids": [a, b], "tint": 0.5})).unwrap();
let stroke_of = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().stroke.clone();
assert_eq!((stroke_of(&s, a).swatch.as_str(), stroke_of(&s, a).tint, stroke_of(&s, a).weight), ("C=100 M=0 Y=0 K=0", 0.5, 2.0));
assert!(stroke_of(&s, b).is_none());
}
}
#[cfg(test)]
mod frame_baseline_grid_tests {
use super::*;
use designcraft_doc::{FrameBaselineGrid, FrameGridRelative};
#[test]
fn text_frame_options_set_a_custom_baseline_grid_relative_to() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let grid = |s: &Session| s.doc().unwrap().doc.item(id).and_then(|i| i.text_frame()).and_then(|t| t.options.baseline_grid);
// Turning it on with only Relative To takes the document grid's start and increment.
let doc = s.doc().unwrap().doc.settings.baseline_grid.clone();
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "frame"}})).unwrap();
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: doc.start, increment: doc.increment, relative_to: FrameGridRelative::TopOfFrame }));
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"start": 10, "increment": 13, "relativeTo": "inset"}})).unwrap();
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopInset }));
// Fields not given keep the frame's.
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "margin"}})).unwrap();
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopMargin }));
for bad in [json!({"relativeTo": "spine"}), json!({"start": f64::MAX}), json!({"increment": -1}), json!(3)] {
assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": bad})).is_err(), "{bad}");
}
assert_eq!(grid(&s).map(|g| g.relative_to), Some(FrameGridRelative::TopMargin));
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": null})).unwrap();
assert_eq!(grid(&s), None);
}
}