Files
designcraft/crates/engine/src/cmd/object.rs
T
.CooCooandDylan M. Taylor 5fb75aaefc Properties: Text Frame section shows inset spacing and vertical justification (#2661)
* Properties: Text Frame section shows inset spacing and vertical justification

Properties › Text Frame now shows the frame's inset spacing under the
columns: Top and Bottom, a link that makes all four the same, then Left
and Right. A non-rectangular frame shows one Inset, the distance its text
keeps from the outline. Four buttons set Vertical Justification to Top,
Center, Bottom or Justify. Type on a path shows neither.

The section also shows with the Type tool in a frame. There it changes
the frame that shows the insertion point, or every frame that holds part
of the selected text, as Object › Text Frame Options does.

* Properties: one Inset for a non-rectangular frame keeps the other sides

The single Inset sets only the top inset, the one kept from the
outline. A rectangular frame that holds part of the same selected text
keeps its own sides.

* Properties: the one Inset field and the composer share the frame outline test

FrameOutline::of_item builds an item's outline with its corner options
applied. The composer uses it to keep the top inset from the outline,
and the Properties panel uses it to decide whether a frame shows one
Inset, so the two give the same answer for every frame.

* Text Frame Options: frames taken while typing stay with their document

OK is refused when another document became active after the dialog opened, so the saved frame ids do not change frames in that document.

* Properties: the inset link starts linked again after Revert

The link's state is kept per document state instead of per document, so
a frame with equal insets in the reverted document starts linked.

* Text Frame Options: frames taken while typing are refused after Revert

Revert keeps the document's id but replaces its state, so a new frame
can reuse a saved frame id. The dialog now ties the frames to the
document state, and OK is refused after Revert as it is in another
document.

* Properties: the one Inset of a non-rectangular frame sets all four sides

The composer bounds a shaped frame's text by its rectangle less all four
insets as well as by the outline, so a single Inset that changed only the
top left hidden sides in place: lowering a uniform 96 pt inset to 0 kept
text overset. When every frame the field changes is shaped, it now sets
all four sides. With a rectangular frame among them, only the top inset
changes and each frame keeps its other sides.

* Fix text frame dialog merge resolution

* Keep text frame preview targets fixed

* Fix target field key inference

* Tests: Text Frame Options show four sides for text through an oval and a rectangle

With the Type tool and text selected through an oval and a threaded
rectangle, with the oval as the caret's stored frame, the dialog shows
the four inset sides, a changed Top leaves the rectangle's other sides
as they were, and the link stays off while the rectangle's sides are
uneven.

* Properties: the inset link starts off when another frame has uneven sides

With selected text that runs through several frames, the link of Text
Frame > Inset Spacing started linked when the frame the section shows had
four equal insets, and a changed Top then set all four sides of every
frame. The link now starts linked only when every frame the field changes
has four equal insets. Its state is kept for that set of frames, so a link
turned on for one frame does not carry over to selected text that also
runs through a frame with uneven sides.

---------

Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
2026-11-12 17:27:08 -05:00

7049 lines
347 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")))
}
/// The fields of a stroke `type` object must be `kind` and the one its kind takes (`pattern` for
/// dashed, `bands` for stripes, `name` for style). `kind` is the type's own, else `base_kind`.
pub(crate) fn check_stroke_type_fields(v: &Value, base_kind: Option<&str>, cmd: &str) -> Result<()> {
let Some(fields) = v.as_object() else { return Ok(()) };
let field = match v.get("kind").and_then(Value::as_str).or(base_kind) {
Some("dashed") => Some("pattern"),
Some("stripes") => Some("bands"),
Some("style") => Some("name"),
_ => None,
};
match fields.keys().find(|key| *key != "kind" && Some(key.as_str()) != field) {
Some(key) => Err(bad(cmd, format!("type: unknown field `{key}`"))),
None => Ok(()),
}
}
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| {
check_stroke_type_fields(v, None, "object.stroke")?;
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), mode?: blend mode (multiply…), ids?}",
has_selection,
|s, p| {
let mode = blend_param(p, "object.dropShadow")?;
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();
}
if let Some(m) = mode {
ds.mode = m;
}
})
}
),
cmd!(
"object.innerShadow",
"Inner Shadow",
["Object", "Effects"],
None,
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, mode?: blend mode, ids?}",
has_selection,
|s, p| {
let mode = blend_param(p, "object.innerShadow")?;
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();
}
if let Some(m) = mode {
e.mode = m;
}
})
}
),
cmd!(
"object.outerGlow",
"Outer Glow",
["Object", "Effects"],
None,
"{on?: bool, opacity?, size?, spread? (%), color?, mode?: blend mode (screen…), ids?}",
has_selection,
|s, p| {
let mode = blend_param(p, "object.outerGlow")?;
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();
}
if let Some(m) = mode {
e.mode = m;
}
})
}
),
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? (1..40), gutter? (pt, 0..15552), inset?: number|[t,l,b,r] (pt, each 0..15552; a list has four sides, null keeps that side), verticalJustification?: top|center|bottom|justify, paragraphSpacingLimit? (pt: Justify adds at most this between paragraphs, the rest between lines), columnsKind?: fixedNumber|fixedWidth|flexibleWidth, 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? (without ids or selected objects: the frame showing the text caret, or every frame holding part of the selected text)}",
has_selection_or_text,
text_frame_options
),
cmd!(
"object.cornerOptions",
"Corner Options…",
["Object"],
None,
"{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size} | null] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size; null keeps that corner of each object), 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, contour, insideEdges or threshold is given; null or those: each object's own, unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping|alphaChannel, insideEdges?: bool (alphaChannel: text may enter transparent holes), content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), threshold?: 0..254 (alphaChannel: pixels more opaque count; default 25), 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.clearFittingOptions",
"Clear Frame Fitting Options",
["Object", "Fitting"],
None,
"{ids?} — the frames' fitting options back to the defaults: no auto-fit, no crop amounts, the centre reference point; the content stays where it is",
has_selection,
clear_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} | {points: [[id, subpath, index, dx?, dy?]], dx?, dy?} (anchors of several paths, as Direct Selection drags them: one step; a point's own dx, dy win over the shared ones) — spread space",
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;
if !renumber(dst, src, &mut copy, &mut story_map) {
return Err(designcraft_doc::DocError::Invalid(format!("{id} nests groups or tables too deeply to copy")).into());
}
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);
}
retarget_copied_xrefs(dst, src, &story_map);
Ok(out)
}
/// Groups, and tables and notes in table cells, nesting deeper than this are not copied: [`renumber`] reports them.
const MAX_COPY_DEPTH: usize = 64;
/// 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. `stories` maps
/// the stories of `src` copied so far to their copies. After the last call with the same `stories`,
/// [`retarget_copied_xrefs`] points the copies' cross-references at the copied anchors. False when its groups, or the
/// tables and notes in the table cells of its stories, nest deeper than [`MAX_COPY_DEPTH`]: the items and tables below
/// that keep their ids, and the copy must not be used.
#[must_use = "false means the copy is incomplete and must not be inserted"]
pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) -> bool {
renumber_in(dst, src, it, stories, 0, true)
}
/// [`renumber`] for an item `depth` groups deep. `adopt` is false inside a story copy, whose asset references the copy
/// has already adopted.
#[must_use = "false means the copy is incomplete and must not be inserted"]
fn renumber_in(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>, depth: usize, adopt: bool) -> bool {
it.id = ItemId(dst.alloc());
let my_id = it.id;
if adopt
&& 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 mut complete = true;
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 marked = renumber_marks(dst, &mut st);
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();
// Mapped first: the map is the seen-set that stops story recursion.
stories.insert(tf.story, n);
complete = renumber_anchored(dst, src, &mut st, stories, depth + 1) && marked;
dst.stories.insert(n, Arc::new(st));
n
}
};
tf.story = new_sid;
if let Some(st) = dst.story_mut(new_sid) {
st.frames.push(my_id);
}
return complete;
}
Content::Graphic(g) => {
if adopt {
g.asset = adopt_or_dangle(dst, src, g.asset);
}
}
Content::Group { items } => {
if depth >= MAX_COPY_DEPTH {
return false;
}
let mut complete = true;
for c in items.iter_mut() {
complete &= renumber_in(dst, src, Arc::make_mut(c), stories, depth + 1, adopt);
}
return complete;
}
Content::Unassigned => {}
}
true
}
/// Give the text anchors and tables of `st`, a new copy of a story, ids of their own (anchors from the story's id,
/// tables from `dst`), as do the tables in the stories of its table cells and notes down to [`MAX_COPY_DEPTH`] levels.
/// The copy's paragraphs follow its tables. False when tables or notes in table cells nest deeper than
/// [`MAX_COPY_DEPTH`]: the tables below that keep their ids, and the copy must not be used.
#[must_use = "false means the copy is incomplete and must not be inserted"]
fn renumber_marks(dst: &mut Document, st: &mut Story) -> bool {
let base = st.id.0 << 24;
for (k, a) in (1..).zip(st.anchors.iter_mut()) {
Arc::make_mut(a).id = base + k;
}
renumber_tables(dst, st, 0)
}
/// Point the cross-references in the story copies in `dst` that `stories` maps at the copies of their destinations
/// where [`renumber`] copied those, in one pass after the last copy. Other cross-references keep their destination.
pub(crate) fn retarget_copied_xrefs(dst: &mut Document, src: &Document, stories: &HashMap<StoryId, StoryId>) {
let anchors = copied_anchors(dst, src, stories);
if !anchors.is_empty() {
retarget_copies(dst, src, stories, &anchors);
}
}
/// The anchor ids of the stories of `src` that `stories` maps, each to the id of its copy's anchor in `dst`.
fn copied_anchors(dst: &Document, src: &Document, stories: &HashMap<StoryId, StoryId>) -> HashMap<u64, u64> {
let mut anchors = HashMap::new();
for (old, new) in stories {
if let (Some(o), Some(n)) = (src.story(*old), dst.story(*new)) {
anchors.extend(o.anchors.iter().zip(&n.anchors).map(|(a, b)| (a.id, b.id)));
}
}
anchors
}
/// Point the cross-references in the copies in `dst` that `stories` lists at the anchors `anchors` gives for their
/// targets. Each copy's targets are found again from its original: a new anchor id can be another original's.
fn retarget_copies(dst: &mut Document, src: &Document, stories: &HashMap<StoryId, StoryId>, anchors: &HashMap<u64, u64>) {
for (old, new) in stories {
if let (Some(o), Some(copy)) = (src.story(*old), dst.story_mut(*new)) {
retarget_xrefs(copy, o, anchors, 0);
}
}
}
/// Give the tables of `st`, and those in the stories of their cells and of the notes of `st` down to
/// [`MAX_COPY_DEPTH`] levels, new ids from `dst`, each story's in the order of their old ids, and point the stories'
/// paragraphs at them. False when the stories below that depth hold tables or notes.
#[must_use = "false means the copy is incomplete and must not be inserted"]
fn renumber_tables(dst: &mut Document, st: &mut Story, depth: usize) -> bool {
let mut tables = HashMap::new();
for (old, mut table) in std::mem::take(&mut st.tables) {
let id = dst.alloc();
Arc::make_mut(&mut table).id = id;
tables.insert(old, id);
st.tables.insert(id, table);
}
for pf in &mut st.paras {
pf.table = pf.table.map(|t| tables.get(&t).copied().unwrap_or(t));
}
if depth >= MAX_COPY_DEPTH {
// The stories below keep the ids of their tables, so the copy is incomplete when they hold tables or notes.
let leaf = |s: &Story| s.tables.is_empty() && s.notes.is_empty() && s.endnotes.is_empty();
return st.tables.values().all(|t| t.cells.iter().all(|c| leaf(&c.text))) && st.notes.iter().chain(&st.endnotes).all(|n| leaf(&n.text));
}
let mut complete = true;
for t in st.tables.values_mut() {
for c in &mut Arc::make_mut(t).cells {
complete &= renumber_tables(dst, &mut c.text, depth + 1);
}
}
for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
complete &= renumber_tables(dst, &mut Arc::make_mut(n).text, depth + 1);
}
complete
}
/// Point the cross-references in `copy`, a copy of `orig`, and in the stories of its table cells and notes down to
/// [`MAX_COPY_DEPTH`] levels, at the anchors `map` gives for the targets of the matching references in `orig`.
fn retarget_xrefs(copy: &mut Story, orig: &Story, map: &HashMap<u64, u64>, depth: usize) {
for (x, o) in copy.xrefs.iter_mut().zip(&orig.xrefs) {
let target = map.get(&o.target).copied().unwrap_or(o.target);
if x.target != target {
Arc::make_mut(x).target = target;
}
}
if depth >= MAX_COPY_DEPTH {
return;
}
// Copied tables have new ids, given in the order of the old ones, so the two maps list them in the same order.
for (t, o) in copy.tables.values_mut().zip(orig.tables.values()) {
for (c, oc) in Arc::make_mut(t).cells.iter_mut().zip(&o.cells) {
retarget_xrefs(&mut c.text, &oc.text, map, depth + 1);
}
}
for (n, o) in copy.notes.iter_mut().zip(&orig.notes).chain(copy.endnotes.iter_mut().zip(&orig.endnotes)) {
retarget_xrefs(&mut Arc::make_mut(n).text, &o.text, map, depth + 1);
}
}
/// Fresh ids for the objects anchored in story copy `st`, its table cells, footnotes and endnotes down to
/// [`MAX_COPY_DEPTH`] levels (#1522), each `depth` levels deep. False when one of them nests groups deeper than
/// [`MAX_COPY_DEPTH`], or when the stories below that level hold anchored objects, tables or notes.
#[must_use = "false means the copy is incomplete and must not be inserted"]
fn renumber_anchored(dst: &mut Document, src: &Document, st: &mut Story, stories: &mut HashMap<StoryId, StoryId>, depth: usize) -> bool {
renumber_anchored_in(dst, src, st, stories, depth, 0)
}
/// [`renumber_anchored`] for a story `level` table cells and notes below the story copy.
#[must_use = "false means the copy is incomplete and must not be inserted"]
fn renumber_anchored_in(
dst: &mut Document,
src: &Document,
st: &mut Story,
stories: &mut HashMap<StoryId, StoryId>,
depth: usize,
level: usize,
) -> bool {
let mut complete = true;
for o in &mut st.objects {
complete &= renumber_in(dst, src, &mut Arc::make_mut(o).item, stories, depth, false);
}
if level >= MAX_COPY_DEPTH {
let leaf = |s: &Story| s.objects.is_empty() && s.tables.is_empty() && s.notes.is_empty() && s.endnotes.is_empty();
return complete
&& st.tables.values().all(|t| t.cells.iter().all(|c| leaf(&c.text)))
&& st.notes.iter().chain(&st.endnotes).all(|n| leaf(&n.text));
}
for t in st.tables.values_mut() {
for c in &mut Arc::make_mut(t).cells {
complete &= renumber_anchored_in(dst, src, &mut c.text, stories, depth, level + 1);
}
}
for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
complete &= renumber_anchored_in(dst, src, &mut Arc::make_mut(n).text, stories, depth, level + 1);
}
complete
}
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);
// A pure move carries the placed graphic along; a resize leaves it at its size.
let c = inner.as_coeffs();
if let Content::Graphic(g) = &mut it.content
&& [c[0] - 1.0, c[1], c[2], c[3] - 1.0].iter().all(|v| v.abs() < 1e-9)
{
g.xf = inner * g.xf;
}
}
}
}
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,
};
// Alpha contours use the document's traced image mask; other unsupported contour types are rejected.
let contour = match p.get("contour").map(|v| serde_json::from_value::<ContourType>(v.clone())) {
Some(Ok(c @ (ContourType::BoundingBox | ContourType::GraphicFrame | ContourType::SameAsClipping | ContourType::AlphaChannel))) => Some(c),
Some(_) => return Err(bad("object.textWrap", "unsupported contour type")),
None => None,
};
let inside_edges =
p.get("insideEdges").map(|v| v.as_bool().ok_or_else(|| bad("object.textWrap", "insideEdges must be a boolean"))).transpose()?;
let threshold = match p.get("threshold") {
Some(v) => Some(v.as_u64().filter(|n| *n <= 254).ok_or_else(|| bad("object.textWrap", "threshold must be 0..254"))? as u8),
None => None,
};
let mode: Option<WrapMode> = match p.get("mode") {
// Null keeps each object's own mode (an edit of the offsets or Invert alone).
Some(Value::Null) => None,
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown mode"))?),
None if side.is_some() || contour.is_some() || inside_edges.is_some() || threshold.is_some() => None,
None => Some(WrapMode::BoundingBox),
};
// Per side [top, left, bottom, right]; a null side keeps each object's own offset there, so
// an edit of one side keeps the others of every object.
// An array with no number in it changes no offset (and so keeps a custom boundary).
let off: Option<[Option<f64>; 4]> = match p.get("offset") {
Some(Value::Number(n)) => Some([n.as_f64(); 4]),
Some(v) => serde_json::from_value::<[Option<f64>; 4]>(v.clone()).ok().filter(|o| o.iter().any(Option::is_some)),
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 {
for (w, v) in w.offsets.iter_mut().zip(o) {
if let Some(v) = v {
*w = v;
}
}
}
if let Some(v) = invert {
w.invert = v;
}
if let Some(v) = side {
w.side = v;
}
if let Some(c) = contour {
w.contour = c;
}
if let Some(v) = inside_edges {
w.inside_edges = v;
}
if let Some(v) = threshold {
w.alpha_threshold = v;
}
// 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: [Option<Corner>; 4] = serde_json::from_value(v.clone())
.map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} or null entries ({e})")))?;
if corners.iter().flatten().any(|c| !c.size.is_finite() || c.size < 0.0) {
return Err(bad("object.cornerOptions", "corner sizes must be finite and not negative"));
}
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)).corners.map(Some)
}
};
// A null corner keeps each object's own.
set_flag(
s,
p,
move |i| {
for (c, new) in i.corners.corners.iter_mut().zip(opts) {
if let Some(new) = new {
*c = new;
}
}
},
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()))?;
// Default effects are left out of the serialized form; a patch without `mode` would
// then read the legacy Normal instead of the effect's current (Multiply, Screen) mode.
for (key, now) in
[("innerShadow", serde_json::to_value(&it.effects.inner_shadow)), ("outerGlow", serde_json::to_value(&it.effects.outer_glow))]
{
if let (Some(o), Ok(now)) = (cur.as_object_mut(), now) {
o.entry(key).or_insert(now);
}
}
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()
})
}
/// The most columns a text frame takes.
const MAX_FRAME_COLUMNS: u64 = 40;
/// The frames `object.textFrameOptions` changes when the call names none: the selected objects,
/// or with the Type tool the frame that shows the caret or every frame holding part of the
/// selected text.
pub fn text_frame_options_frames(s: &Session) -> Vec<ItemId> {
let Ok(st) = s.doc() else { return Vec::new() };
if !st.selection.items.is_empty() {
return st.selection.items.clone();
}
st.selection.text.map(|t| text_selection_frames(s, &t)).unwrap_or_default()
}
/// Text Frame Options act on the selected objects, or with the Type tool on the frames that hold
/// the caret or the selected text.
fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.selection.text.is_some()) {
return Ok(());
}
has_selection(s)
}
fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "object.textFrameOptions";
let st = s.doc()?;
// A call that names its objects acts on those; an empty `ids` names nothing. `ids`, when
// given, wins over `id`, and a value that is not an object id is an error.
let ids = match (p.get("ids"), p.get("id")) {
(Some(v), _) => v
.as_array()
.and_then(|a| a.iter().map(|x| x.as_u64().map(ItemId)).collect::<Option<Vec<_>>>())
.ok_or_else(|| bad(ID, format!("ids must be a list of object ids, not {v}")))?,
(None, Some(v)) => vec![v.as_u64().map(ItemId).ok_or_else(|| bad(ID, format!("id must be an object id, not {v}")))?],
(None, None) => text_frame_options_frames(s),
};
// With a text caret the command runs before named objects are checked.
if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) {
return Err(bad(ID, format!("no object with id {}", gone.0)));
}
if ids.is_empty() {
return Err(bad(ID, super::NOTHING_SELECTED));
}
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 spacing_limit = ranged("paragraphSpacingLimit", 0.0, 15552.0, "a number of points")?;
let gutter = ranged("gutter", 0.0, 15552.0, "a number of points")?;
let columns = match p.get("columns") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_u64()
.filter(|n| (1..=MAX_FRAME_COLUMNS).contains(n))
.ok_or_else(|| bad(ID, format!("columns must be a whole number from 1 to {MAX_FRAME_COLUMNS}, not {v}")))? as u32,
),
};
let flag = |key: &str| match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(v) => Err(bad(ID, format!("{key} must be true or false, not {v}"))),
};
let use_min_height = flag("useMinimumHeight")?;
let use_min_width = flag("useMinimumWidth")?;
let no_line_breaks = flag("noLineBreaks")?;
let ignore_wrap = flag("ignoreWrap")?;
let balance_columns = flag("balanceColumns")?;
let column_rule = flag("columnRule")?;
let vertical = flag("vertical")?;
let inset = frame_inset_param(p)?;
let vertical_justification: Option<designcraft_doc::VerticalJustification> =
frame_choice_param(p, "verticalJustification", "top|center|bottom|justify")?;
let first_baseline: Option<designcraft_doc::FirstBaseline> =
frame_choice_param(p, "firstBaseline", "ascent|capHeight|leading|xHeight|fixed|emboxHeight")?;
let auto_size: Option<designcraft_doc::AutoSize> =
frame_choice_param(p, "autoSize", "off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally")?;
let columns_kind: Option<designcraft_doc::ColumnsKind> = frame_choice_param(p, "columnsKind", "fixedNumber|fixedWidth|flexibleWidth")?;
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 (before, revision) = (Arc::clone(&st.doc), st.revision);
let find = s.find_context.take();
let p = p.clone();
let edited = 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) = columns {
o.columns = v;
}
if let Some(v) = gutter {
o.gutter = v;
}
for (side, v) in o.inset.iter_mut().zip(inset.unwrap_or_default()) {
if let Some(v) = v {
*side = v;
}
}
if let Some(v) = vertical_justification {
o.vertical_justification = v;
}
if let Some(v) = first_baseline {
o.first_baseline = v;
}
if let Some(v) = auto_size {
o.auto_size = v;
}
if let Some(v) = columns_kind {
o.columns_kind = v;
}
if let Some(v) = reference {
o.auto_size_ref = v;
}
o.vj_paragraph_spacing_limit = spacing_limit.unwrap_or(o.vj_paragraph_spacing_limit);
if let Some(v) = use_min_height {
o.auto_size_use_min_height = v;
}
if let Some(v) = min_height {
o.auto_size_min_height = v;
}
if let Some(v) = use_min_width {
o.auto_size_use_min_width = v;
}
if let Some(v) = min_width {
o.auto_size_min_width = v;
}
if let Some(v) = no_line_breaks {
o.auto_size_no_line_breaks = v;
}
if let Some(v) = ignore_wrap {
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) = balance_columns {
o.balance_columns = v;
}
if let Some(v) = column_rule {
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;
}
apply_custom_frame_grid(o, custom_grid, doc_grid);
// 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)
&& ((r.width() - width).abs() >= 1e-9 || !width.is_finite())
{
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) = vertical {
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()}))
});
let changed = match edited {
Ok(v) => v,
Err(e) => {
s.find_context = find;
return Err(e);
}
};
// Options the frames already have: the document, its revision and the Find Next scope go back
// to what they were, which leaves no undo step. Only the frames and their stories are compared.
let st = s.doc_mut()?;
let same = ids.iter().all(|id| {
let (now, was) = (st.doc.item(*id), before.item(*id));
now == was && now.and_then(Item::text_frame).is_none_or(|t| st.doc.story(t.story) == before.story(t.story))
});
if same {
st.doc = before;
st.revision = revision;
s.find_context = find;
return Ok(json!({"changed": 0}));
}
Ok(changed)
}
/// The frames Text Frame Options acts on from the Type tool: the frame that shows the caret, or
/// every frame that holds part of the selected text. Text past the last frame counts as the last
/// frame's.
fn text_selection_frames(s: &Session, t: &TextSel) -> Vec<ItemId> {
let Ok(st) = s.doc() else { return Vec::new() };
let Some(story) = st.doc.story(t.story) else { return Vec::new() };
let last = story.frames.last().copied();
let cs = s.cache.get(&st.doc, t.story, None);
// In footnote text or a table cell, `anchor` and `focus` index that text: the frame the caret
// is drawn in (the placed footnote, or the cell's first non-repeated occurrence), else the
// frame that shows the footnote reference or the table's anchor character.
if let Some(c) = t.cell {
let (drawn, anchor) = match c.footnote_id() {
Some(id) => (designcraft_compose::find_note(&cs, id).map(|(fi, _)| fi), story.note_anchor(id)),
None => (designcraft_compose::find_cell(&cs, c.table, c.row, c.col).map(|(fi, ..)| fi), story.table_anchor(c.table)),
};
return drawn
.or_else(|| anchor.and_then(|a| designcraft_compose::caret(&cs, a)).map(|(fi, ..)| fi))
.and_then(|fi| cs.frames.get(fi))
.map(|f| f.frame)
.or_else(|| t.frame.filter(|f| story.frames.contains(f)))
.or(last)
.into_iter()
.collect();
}
let r = t.range();
// The frame the caret at the start is drawn in; the caret's stored frame only when no line
// holds it.
let caret_frame = || {
designcraft_compose::caret(&cs, r.start)
.and_then(|(fi, ..)| cs.frames.get(fi))
.map(|f| f.frame)
.or_else(|| t.frame.filter(|f| story.frames.contains(f) && cs.overset_at.is_none_or(|o| r.start < o)))
};
// A frame's range stops before the paragraph return that ends its last line; the return
// belongs to that frame.
let end_of = |f: &designcraft_compose::FrameText| f.range.end + usize::from(story.text.as_bytes().get(f.range.end) == Some(&b'\n'));
let mut frames: Vec<ItemId> = if t.is_caret() {
caret_frame().into_iter().collect()
} else {
cs.frames.iter().filter(|f| f.range.start < r.end && r.start < end_of(f)).map(|f| f.frame).collect()
};
// Selected text no frame holds goes with the line before it.
if frames.is_empty() {
frames.extend(caret_frame());
}
let overset = cs.overset_at.is_some_and(|o| r.end > o);
if let Some(l) = last
&& (frames.is_empty() || overset)
&& !frames.contains(&l)
{
frames.push(l);
}
frames
}
/// `object.textFrameOptions` Inset Spacing: one number for every side, or [top, left, bottom,
/// right] with null for a side that keeps its value; each side 0 to 15552 pt.
fn frame_inset_param(p: &Value) -> Result<Option<[Option<f64>; 4]>> {
const ID: &str = "object.textFrameOptions";
let side = |v: &Value| match v.as_f64() {
Some(x) if x.is_finite() && (0.0..=15552.0).contains(&x) => Ok(x),
_ => Err(bad(ID, format!("inset must be a number of points from 0 to 15552, not {v}"))),
};
match p.get("inset") {
None | Some(Value::Null) => Ok(None),
Some(v @ Value::Number(_)) => Ok(Some([Some(side(v)?); 4])),
Some(Value::Array(a)) if a.len() == 4 => {
let mut sides = [None; 4];
for (s, v) in sides.iter_mut().zip(a) {
if !v.is_null() {
*s = Some(side(v)?);
}
}
Ok(Some(sides))
}
Some(v) => Err(bad(ID, format!("inset must be a number or [top, left, bottom, right], not {v}"))),
}
}
/// An `object.textFrameOptions` choice: absent or null leaves it, a name outside `names` is an error.
fn frame_choice_param<T: serde::de::DeserializeOwned>(p: &Value, key: &str, names: &str) -> Result<Option<T>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => {
serde_json::from_value(v.clone()).map(Some).map_err(|_| bad("object.textFrameOptions", format!("{key} must be one of {names}, not {v}")))
}
}
}
fn apply_custom_frame_grid(
options: &mut TextFrameOptions,
custom: Option<Option<(Option<f64>, Option<f64>, Option<designcraft_doc::FrameGridRelative>)>>,
document_grid: designcraft_doc::FrameBaselineGrid,
) {
match custom {
Some(Some((start, increment, relative_to))) => {
let mut grid = options.baseline_grid.unwrap_or(document_grid);
grid.start = start.unwrap_or(grid.start);
grid.increment = increment.unwrap_or(grid.increment);
grid.relative_to = relative_to.unwrap_or(grid.relative_to);
options.baseline_grid = Some(grid);
}
Some(None) => options.baseline_grid = None,
None => {}
}
}
/// 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) {
// A crop too big to trace is skipped, as a graphic that fails to decode is: tracing
// the whole page instead would trace something else than the box.
Some(frac) => match designcraft_render::crop_pixmap(&px, frac) {
Some(cropped) => cropped,
None => continue,
},
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;
}
// Turning Auto-Fit off alone leaves the picture where the user put it.
let turn_off_only = auto == Some(false) && fitting.is_none() && align.is_none() && crop.is_none();
if !turn_off_only && let Some(xf) = g.fitted(r, mode) {
g.xf = xf;
}
n += 1;
}
Ok(json!({"changed": n}))
})
}
/// Clear Frame Fitting Options: auto-fit off, no crop, the centre reference point. The content
/// stays where it is, as in InDesign.
fn clear_fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
// Locked frames (or frames on a locked layer) keep their options, as with Fit; frames whose
// options are already clear are left out; with nothing to change, no edit (and no empty undo
// step).
let doc = &s.doc()?.doc;
let set = |g: &designcraft_doc::Graphic| g.auto_fit != designcraft_doc::Fitting::None || g.crop != [0.0; 4] || g.fit_align != 4;
let ids: Vec<ItemId> = targets(s, p)?
.into_iter()
.filter(|id| doc.item(*id).is_some_and(|it| unlocked(doc, it) && matches!(&it.content, Content::Graphic(g) if set(g))))
.collect();
if ids.is_empty() {
return Ok(json!({"changed": 0}));
}
s.edit(|d, _| {
let mut n = 0;
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
let Content::Graphic(g) = &mut it.content else { continue };
g.auto_fit = designcraft_doc::Fitting::None;
g.crop = [0.0; 4];
g.fit_align = 4;
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()
})
}
/// An effect's `mode` parameter: a blend mode by name ("multiply", "screen", or as saved).
/// An effect's `mode`: `None` when omitted or null, an error when it names no blend mode.
fn blend_param(p: &Value, cmd: &str) -> Result<Option<BlendMode>> {
match p.get("mode") {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_str()
.and_then(BlendMode::parse)
.or_else(|| serde_json::from_value(v.clone()).ok())
.map(Some)
.ok_or_else(|| bad(cmd, format!("unknown blend mode {v}"))),
}
}
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"))?;
// An omitted subpath is the last one; one given must name a subpath (never falls back).
let subpath = match p.get("subpath") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().ok_or_else(|| bad("path.appendAnchor", "`subpath` must be a subpath index"))?),
};
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, sel| {
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 si = match subpath {
Some(i) => usize::try_from(i).ok().filter(|i| *i < it.path.subpaths.len()).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?,
None => it.path.subpaths.len().checked_sub(1).ok_or_else(|| bad("path.appendAnchor", "empty path"))?,
};
let sp = &mut it.path.subpaths[si];
if sp.closed {
return Err(bad("path.appendAnchor", "the subpath is closed"));
}
if at_start {
sp.anchors.insert(0, a.transform(inv));
// The selected points of this sub-path move up one place with the others.
for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si) {
a.2 += 1;
}
} 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, sel| {
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;
}
// Selected points keep pointing at the same points, now counted from the other end.
let n = sp.anchors.len();
for a in sel.anchors.iter_mut().filter(|a| a.0 == *id && a.1 == i && a.2 < n) {
a.2 = n - 1 - a.2;
}
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> {
// The anchors to move, by path: `points` (several paths) or `id` with `anchors`.
// Each anchor with its own spread-space movement, when the points give one.
let mut targets: Vec<(ItemId, Vec<(usize, usize, Option<Vec2>)>)> = Vec::new();
// A `points` that isn't a list is an error, never the `id` form (which could move another path).
let points = match p.get("points") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) => Some(a),
Some(_) => return Err(bad("path.moveAnchors", "`points` must be a list of [id, subpath, index, dx?, dy?]")),
};
if let Some(points) = points {
for v in points {
let n = |i: usize| v.get(i).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
let (Some(id), Some(si), Some(ai)) = (v.get(0).and_then(Value::as_u64), n(1), n(2)) else {
return Err(bad("path.moveAnchors", "each point is [id, subpath, index] or [id, subpath, index, dx, dy]"));
};
let own = match (v.get(3), v.get(4)) {
(None, None) => None,
(Some(x), Some(y)) => match (x.as_f64(), y.as_f64()) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Vec2::new(x, y)),
_ => return Err(bad("path.moveAnchors", "a point's dx and dy are finite numbers")),
},
_ => return Err(bad("path.moveAnchors", "a point gives both dx and dy, or neither")),
};
match targets.iter_mut().find(|(i, _)| i.0 == id) {
Some((_, which)) => which.push((si, ai, own)),
None => targets.push((ItemId(id), vec![(si, ai, own)])),
}
}
} else {
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();
targets.push((id, which.into_iter().map(|(s, a)| (s, a, None)).collect()));
}
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, _| {
for (id, _) in &targets {
path_editable(d, *id, "path.moveAnchors")?;
}
for (id, which) in &targets {
let id = *id;
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 inner = |d: Vec2| (inv * Point::new(d.x, d.y)) - (inv * Point::ZERO);
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
for (si, ai, own) in which {
let dv = inner(own.unwrap_or(d0));
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::*;
/// The shadow and glow commands take a blend mode by name.
#[test]
fn shadow_and_glow_commands_take_a_blend_mode() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.dropShadow", &json!({"on": true, "mode": "screen", "ids": [id]})).unwrap();
s.execute("object.innerShadow", &json!({"on": true, "mode": "overlay", "ids": [id]})).unwrap();
s.execute("object.outerGlow", &json!({"on": true, "mode": "normal", "ids": [id]})).unwrap();
let fx = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.clone();
assert_eq!(fx.drop_shadow.mode, BlendMode::Screen);
assert_eq!(fx.inner_shadow.mode, BlendMode::Overlay);
assert_eq!(fx.outer_glow.mode, BlendMode::Normal);
let before = s.doc().unwrap().doc.clone();
for cmd in ["object.dropShadow", "object.innerShadow", "object.outerGlow"] {
for mode in [json!("multply"), json!(3)] {
let e = s.execute(cmd, &json!({"on": true, "mode": mode, "ids": [id]})).unwrap_err();
assert!(e.to_string().contains("unknown blend mode"), "{cmd}: {e}");
}
}
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed");
s.execute("object.dropShadow", &json!({"mode": null, "ids": [id]})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.drop_shadow.mode, BlendMode::Screen, "null keeps the mode");
}
#[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 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 stays vector, as a knockout transparency group.
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("1 object(s)")), "{r}");
assert!(String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).contains("/K true"));
// 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);
}
/// Turning Auto-Fit off by itself keeps a moved picture where it is.
#[test]
fn auto_fit_off_alone_keeps_the_picture_position() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap();
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap();
s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally"})).unwrap();
let xf = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().xf;
s.edit(|d, _| {
if let Some(Content::Graphic(g)) = d.item_mut(ItemId(f)).map(|it| &mut it.content) {
g.xf = designcraft_geom::Affine::translate((7.0, 9.0)) * g.xf;
}
Ok(Value::Null)
})
.unwrap();
let moved = xf(&s);
s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": false})).unwrap();
assert_eq!(xf(&s), moved);
}
/// Clear Frame Fitting Options puts auto-fit, crop and the reference point back to their
/// defaults and leaves the picture where it is.
#[test]
fn clear_fitting_options_resets_the_frames_options_and_keeps_the_picture() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap();
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap();
s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally", "crop": 10, "align": 0})).unwrap();
let graphic = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().clone();
let before = graphic(&s);
assert_ne!(before.auto_fit, designcraft_doc::Fitting::None);
s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap();
let g = graphic(&s);
assert_eq!(g.auto_fit, designcraft_doc::Fitting::None);
assert_eq!(g.crop, [0.0; 4]);
assert_eq!(g.fit_align, 4, "the centre reference point");
assert_eq!(g.xf, before.xf, "the picture stays where it is");
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap();
assert_eq!((r["changed"].as_u64(), s.doc().unwrap().history.undo.len()), (Some(0), undo), "already clear: no change, no step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step");
s.execute("object.lock", &json!({"ids": [f]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap();
assert_eq!(r["changed"], 0);
assert_eq!(graphic(&s).crop, [10.0; 4], "a locked frame keeps its options");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no empty undo step");
}
}
#[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());
}
/// A placed PDF whose crop box is too large to trace is skipped, not traced from the whole page.
#[test]
fn clipping_path_skips_a_pdf_whose_crop_is_too_large_to_trace() {
let content = "0 0 0 rg 10 10 50 50 re f";
let objs = [
"<</Type/Catalog/Pages 2 0 R>>".to_string(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[-30000 -30000 30000 30000]/CropBox[0 0 100 100]/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n");
for at in offsets {
bytes.extend_from_slice(format!("{at:010} 00000 n\r\n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<</Size 5/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
let placed = s
.execute("file.place", &json!({"base64": super::super::file::base64_encode(&bytes), "name": "wide.pdf", "frame": f, "pdfCrop": "media"}));
assert!(placed.is_ok(), "{placed:?}");
let before = s.doc().unwrap().doc.item(ItemId(f)).unwrap().shape;
let e = s.execute("object.clippingPath", &json!({"ids": [f], "type": "alpha"}));
assert!(e.is_err(), "the graphic is skipped, so there is nothing to trace: {e:?}");
assert_eq!(s.doc().unwrap().doc.item(ItemId(f)).unwrap().shape, before, "the frame keeps its shape");
}
#[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");
s.execute("edit.deselectAll", &json!({})).unwrap();
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 type_tool_frame_options_tests {
use super::*;
use designcraft_doc::VerticalJustification;
/// One story threaded through two frames, with text in both: (first frame, second frame, story).
fn two_frame_thread(s: &mut Session) -> (ItemId, ItemId, StoryId) {
s.execute("file.new", &json!({})).unwrap();
let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::<Vec<_>>().join(" ");
let a = s.execute("frame.create", &json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap();
let b = s.execute("frame.create", &json!({"rect": [36, 200, 300, 700], "content": "text", "text": ""})).unwrap();
let (a, b) = (ItemId(a["id"].as_u64().unwrap()), ItemId(b["id"].as_u64().unwrap()));
s.execute("text.thread", &json!({"from": a.0, "to": b.0})).unwrap();
let sid = s.doc().unwrap().doc.item(a).and_then(Item::text_frame).unwrap().story;
(a, b, sid)
}
/// The story bytes `frame` shows.
fn shown(s: &Session, sid: StoryId, frame: ItemId) -> std::ops::Range<usize> {
let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default());
cs.frame(frame).unwrap().range.clone()
}
fn options(s: &Session, id: ItemId) -> TextFrameOptions {
s.doc().unwrap().doc.item(id).and_then(Item::text_frame).unwrap().options.clone()
}
fn undo_steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// With the Type tool, Text Frame Options change the frame that shows the caret, not every
/// frame of the story.
#[test]
fn a_caret_changes_the_frame_it_is_in() {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
let (ra, rb) = (shown(&s, sid, a), shown(&s, sid, b));
assert!(!ra.is_empty() && !rb.is_empty(), "both frames hold text: {ra:?} {rb:?}");
s.execute("text.select", &json!({"story": sid.0, "anchor": rb.start + 2})).unwrap();
assert!(s.doc().unwrap().selection.items.is_empty());
let steps = undo_steps(&s);
s.execute("object.textFrameOptions", &json!({"columns": 3, "verticalJustification": "center", "inset": 6})).unwrap();
assert_eq!(
(options(&s, b).columns, options(&s, b).vertical_justification, options(&s, b).inset),
(3, VerticalJustification::Center, [6.0; 4])
);
assert_eq!((options(&s, a).columns, options(&s, a).vertical_justification), (1, VerticalJustification::Top), "the first frame is left alone");
assert_eq!(undo_steps(&s), steps + 1, "one undo step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(options(&s, b).columns, 1);
// A caret in the first frame changes the first frame.
s.execute("text.select", &json!({"story": sid.0, "anchor": ra.start + 2})).unwrap();
s.execute("object.textFrameOptions", &json!({"verticalJustification": "bottom"})).unwrap();
assert_eq!(
(options(&s, a).vertical_justification, options(&s, b).vertical_justification),
(VerticalJustification::Bottom, VerticalJustification::Top)
);
}
/// A caret moved to the start of the second frame keeps the first frame as its stored frame;
/// the options go to the frame the caret is drawn in.
#[test]
fn a_caret_at_a_frame_boundary_changes_the_frame_it_is_drawn_in() {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
let rb = shown(&s, sid, b);
let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default());
let drawn = designcraft_compose::caret(&cs, rb.start).and_then(|(fi, ..)| cs.frames.get(fi)).map(|f| f.frame);
assert_eq!(drawn, Some(b), "the caret at the boundary is drawn in the second frame");
s.doc_mut().unwrap().selection = Selection::text(TextSel { story: sid, anchor: rb.start, focus: rb.start, frame: Some(a), cell: None });
s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap();
assert_eq!((options(&s, a).columns, options(&s, b).columns), (1, 2));
}
/// Selected text that runs from one frame into the next changes both frames.
#[test]
fn selected_text_changes_every_frame_it_runs_through() {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
let (ra, rb) = (shown(&s, sid, a), shown(&s, sid, b));
s.execute("text.select", &json!({"story": sid.0, "anchor": ra.end - 3, "focus": rb.start + 3})).unwrap();
s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap();
assert_eq!((options(&s, a).columns, options(&s, b).columns), (2, 2));
// Text inside one frame changes that frame only.
s.execute("text.select", &json!({"story": sid.0, "anchor": rb.start + 1, "focus": rb.start + 6})).unwrap();
s.execute("object.textFrameOptions", &json!({"columns": 4})).unwrap();
assert_eq!((options(&s, a).columns, options(&s, b).columns), (2, 4));
}
/// A selected paragraph return at the end of the first frame changes the first frame.
#[test]
fn a_selected_return_at_the_end_of_a_frame_changes_that_frame() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let text = (0..40).map(|i| format!("Paragraph {i}")).collect::<Vec<_>>().join("\n");
let a = s.execute("frame.create", &json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap();
let b = s.execute("frame.create", &json!({"rect": [36, 200, 300, 700], "content": "text", "text": ""})).unwrap();
let (a, b) = (ItemId(a["id"].as_u64().unwrap()), ItemId(b["id"].as_u64().unwrap()));
s.execute("text.thread", &json!({"from": a.0, "to": b.0})).unwrap();
let sid = s.doc().unwrap().doc.item(a).and_then(Item::text_frame).unwrap().story;
let (ra, rb) = (shown(&s, sid, a), shown(&s, sid, b));
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text.as_bytes()[ra.end], b'\n', "the first frame ends before a return");
assert_eq!(rb.start, ra.end + 1, "the second frame starts after it");
s.execute("text.select", &json!({"story": sid.0, "anchor": ra.end, "focus": ra.end + 1})).unwrap();
s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap();
assert_eq!((options(&s, a).columns, options(&s, b).columns), (2, 1));
// From that return into the second frame, with the first frame back as it was: both frames.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((shown(&s, sid, a), shown(&s, sid, b)), (ra.clone(), rb.clone()));
s.execute("text.select", &json!({"story": sid.0, "anchor": ra.end, "focus": rb.start + 3})).unwrap();
s.execute("object.textFrameOptions", &json!({"columns": 3})).unwrap();
assert_eq!((options(&s, a).columns, options(&s, b).columns), (3, 3));
}
/// An empty `ids` names no frame, so it does not fall back to the caret's frame.
#[test]
fn empty_ids_with_a_caret_is_an_error() {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
s.execute("text.select", &json!({"story": sid.0, "anchor": 2})).unwrap();
let before = s.doc().unwrap().doc.clone();
let e = s.execute("object.textFrameOptions", &json!({"ids": [], "columns": 3})).unwrap_err().to_string();
assert!(e.contains("nothing selected"), "{e}");
// An id that is not in the document is refused too.
let e = s.execute("object.textFrameOptions", &json!({"ids": [99999, b.0], "columns": 3})).unwrap_err().to_string();
assert!(e.contains("no object with id 99999"), "{e}");
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed");
assert_eq!((options(&s, a).columns, options(&s, b).columns), (1, 1));
}
/// An `ids` that is not a list of object ids is refused, not read as the selected frame or the
/// caret's frame.
#[test]
fn malformed_ids_are_an_error() {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
s.execute("selection.set", &json!({"ids": [b.0]})).unwrap();
let before = s.doc().unwrap().doc.clone();
for p in [json!({"ids": "bad", "columns": 2}), json!({"ids": [b.0, "x"], "columns": 2}), json!({"ids": null, "columns": 2})] {
let e = s.execute("object.textFrameOptions", &p).unwrap_err().to_string();
assert!(e.contains("ids must be a list of object ids"), "{p}: {e}");
}
s.execute("text.select", &json!({"story": sid.0, "anchor": 2})).unwrap();
let e = s.execute("object.textFrameOptions", &json!({"ids": "bad", "columns": 2})).unwrap_err().to_string();
assert!(e.contains("ids must be a list of object ids"), "with a caret: {e}");
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed");
assert_eq!((options(&s, a).columns, options(&s, b).columns), (1, 1));
}
/// Options the frame already has change nothing and add no undo step.
#[test]
fn unchanged_options_add_no_undo_step() {
let mut s = Session::new();
let (_, b, sid) = two_frame_thread(&mut s);
let rb = shown(&s, sid, b);
s.execute("text.select", &json!({"story": sid.0, "anchor": rb.start + 2})).unwrap();
let steps = undo_steps(&s);
let (before, revision) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().revision);
let r = s.execute("object.textFrameOptions", &json!({"columns": 1, "verticalJustification": "top", "inset": 0})).unwrap();
assert_eq!(r["changed"], 0);
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed");
assert_eq!(s.doc().unwrap().revision, revision, "the revision stays");
assert_eq!(undo_steps(&s), steps);
// The same holds for selected frames, Fixed Width columns included.
s.execute("selection.set", &json!({"ids": [b.0]})).unwrap();
s.execute("object.textFrameOptions", &json!({"gutter": options(&s, b).gutter})).unwrap();
assert_eq!(undo_steps(&s), steps);
let fixed = json!({"columnsKind": "fixedWidth", "columnWidth": 100.3, "columns": 2, "gutter": 12.1});
s.execute("object.textFrameOptions", &fixed).unwrap();
assert_eq!(undo_steps(&s), steps + 1);
assert_eq!(s.execute("object.textFrameOptions", &fixed).unwrap()["changed"], 0);
assert_eq!(undo_steps(&s), steps + 1, "the same Fixed Width options again add no undo step");
}
/// A caret in footnote text changes the frame that shows the footnote, with or without a
/// stored frame on the caret.
#[test]
fn a_caret_in_a_footnote_changes_the_frame_that_shows_it() {
for (in_second, stored) in [(false, None), (true, None), (false, Some(true))] {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
let r = shown(&s, sid, if in_second { b } else { a });
s.execute("text.select", &json!({"story": sid.0, "anchor": r.start + 2})).unwrap();
s.execute("footnote.insert", &json!({"text": "Note."})).unwrap();
let mut t = s.doc().unwrap().selection.text.unwrap();
assert!(t.cell.and_then(|c| c.footnote_id()).is_some(), "the caret is in the footnote: {t:?}");
if let Some(second) = stored {
t.frame = Some(if second { b } else { a });
s.doc_mut().unwrap().selection = Selection::text(t);
}
s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap();
let want = if in_second { (1, 2) } else { (2, 1) };
assert_eq!((options(&s, a).columns, options(&s, b).columns), want, "footnote in the second frame: {in_second}, stored frame: {stored:?}");
}
}
/// A caret in a table cell changes the frame that shows the cell, with or without a stored
/// frame on the caret.
#[test]
fn a_caret_in_a_table_cell_changes_the_frame_that_shows_it() {
for (in_second, stored) in [(false, None), (true, None), (false, Some(true))] {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
let r = shown(&s, sid, if in_second { b } else { a });
s.execute("text.select", &json!({"story": sid.0, "anchor": r.start + 2})).unwrap();
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
let mut t = s.doc().unwrap().selection.text.unwrap();
assert!(t.cell.is_some_and(|c| c.footnote_id().is_none()), "the caret is in the cell: {t:?}");
if let Some(second) = stored {
t.frame = Some(if second { b } else { a });
s.doc_mut().unwrap().selection = Selection::text(t);
}
s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap();
let want = if in_second { (1, 2) } else { (2, 1) };
assert_eq!((options(&s, a).columns, options(&s, b).columns), want, "table in the second frame: {in_second}, stored frame: {stored:?}");
}
}
/// A caret in a repeated footer row is drawn in the footer's own occurrence at the end of the
/// table: that frame changes, not the earlier frame that shows a repeat of the footer.
#[test]
fn a_caret_in_a_repeated_footer_changes_the_frame_with_the_footer_itself() {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
let ra = shown(&s, sid, a);
s.execute("text.select", &json!({"story": sid.0, "anchor": ra.start + 2})).unwrap();
s.execute("table.insert", &json!({"rows": 15, "cols": 1, "footerRows": 1})).unwrap();
s.execute("table.options", &json!({"repeatFooter": true})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
let table = t.cell.unwrap().table;
let footer = s.doc().unwrap().doc.story(sid).unwrap().tables.get(&table).unwrap().nrows() - 1;
let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default());
let placed = |f: ItemId| {
cs.frame(f)
.unwrap()
.tables
.iter()
.filter(|tf| tf.table == table)
.filter_map(|tf| tf.cell(footer, 0))
.map(|c| c.repeated)
.collect::<Vec<_>>()
};
assert_eq!((placed(a), placed(b)), (vec![true], vec![false]), "the footer repeats in the first frame and ends the table in the second");
s.doc_mut().unwrap().selection =
Selection::text(TextSel { cell: Some(designcraft_doc::CellAddr { table, row: footer, col: 0 }), anchor: 0, focus: 0, frame: None, ..t });
s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap();
assert_eq!((options(&s, a).columns, options(&s, b).columns), (1, 2));
}
}
#[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 outside 1 to 40 is refused, past `u32::MAX` too (2^32 + 1 must not
/// wrap to 1 column), and the frame keeps its columns.
#[test]
fn column_count_outside_one_to_forty_is_refused() {
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 in [4_294_967_297_u64, u64::MAX, 41, 0] {
let e = s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": given})).expect_err("refused").to_string();
assert!(e.contains("columns must be a whole number from 1 to 40"), "columns {given}: {e}");
assert_eq!(columns(&s), 1, "columns {given}");
}
for given in [40, 1] {
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": given})).unwrap();
assert_eq!(columns(&s), given as u32, "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()));
}
/// A selected text frame whose story holds a paragraph anchor, a cross-reference to it and a table.
fn marked_frame(s: &mut crate::Session) -> (u64, designcraft_doc::StoryId) {
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let text = json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Results\nSee "});
let f = s.execute("frame.create", &text).unwrap()["id"].as_u64().unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().text_frame().unwrap().story;
s.execute("text.select", &json!({"story": sid.0, "anchor": 12, "focus": 12})).unwrap();
s.execute("xref.insert", &json!({"paragraph": "Results"})).unwrap();
s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap();
s.execute("selection.set", &json!({"ids": [f]})).unwrap();
(f, sid)
}
/// The copy of story `orig` made for item `copy` has a text anchor and a table of its own, and its cross-reference
/// points at its own anchor. A table command naming the copy's table changes the copy alone.
fn assert_own_marks(s: &mut crate::Session, orig: designcraft_doc::StoryId, copy: u64) {
let d = s.doc().unwrap().doc.clone();
let sid = d.item(designcraft_doc::ItemId(copy)).unwrap().text_frame().unwrap().story;
assert_ne!(sid, orig);
let (o, c) = (d.story(orig).unwrap(), d.story(sid).unwrap());
let (oa, ca) = (o.anchors[0].id, c.anchors[0].id);
assert_ne!(oa, ca, "the copy's anchor has an id of its own");
assert_eq!(d.find_anchor(ca).map(|(at, _)| at), Some(sid));
assert_eq!((o.xrefs[0].target, c.xrefs[0].target), (oa, ca), "each cross-reference points at its own story's anchor");
let (ot, ct) = (*o.tables.keys().next().unwrap(), *c.tables.keys().next().unwrap());
assert_ne!(ot, ct, "the copy's table has an id of its own");
assert_eq!(c.tables[&ct].id, ct);
assert_eq!(c.paras.iter().filter_map(|pf| pf.table).collect::<Vec<_>>(), [ct], "the copied paragraph holds the copied table");
d.check().unwrap();
// The table commands are enabled with the insertion point in a table: here the original's.
let cell = designcraft_doc::CellAddr { table: ot, row: 0, col: 0 };
s.edit(|_, sel| {
sel.text = Some(designcraft_doc::TextSel { story: orig, anchor: 0, focus: 0, frame: None, cell: Some(cell) });
Ok(())
})
.unwrap();
s.execute("table.insertRow", &json!({"table": ct})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.story(orig).unwrap().tables[&ot].nrows(), 1, "the original table is unchanged");
assert_eq!(d.story(sid).unwrap().tables[&ct].nrows(), 2);
}
#[test]
fn duplicated_text_gets_anchors_and_tables_of_its_own() {
let mut s = crate::Session::new();
let (_, sid) = marked_frame(&mut s);
let copy = s.execute("edit.duplicate", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
assert_own_marks(&mut s, sid, copy);
}
#[test]
fn pasted_text_gets_anchors_and_tables_of_its_own() {
let mut s = crate::Session::new();
let (_, sid) = marked_frame(&mut s);
s.execute("edit.copy", &json!({})).unwrap();
let copy = s.execute("edit.paste", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
assert_own_marks(&mut s, sid, copy);
}
#[test]
fn duplicated_spreads_get_anchors_and_tables_of_their_own() {
let mut s = crate::Session::new();
let (f, sid) = marked_frame(&mut s);
s.execute("layout.pages.duplicateSpread", &json!({"spread": 0})).unwrap();
let d = &s.doc().unwrap().doc;
let copy = d.spreads[1].items.iter().find(|i| i.text_frame().is_some()).unwrap().id.0;
assert_ne!(copy, f);
assert_own_marks(&mut s, sid, copy);
}
#[test]
fn copied_text_gives_tables_in_its_cells_ids_of_their_own() {
use std::sync::Arc;
fn table_ids(st: &designcraft_doc::Story, out: &mut Vec<u64>) {
for (id, t) in &st.tables {
out.push(*id);
for c in &t.cells {
table_ids(&c.text, out);
}
}
}
let cell = |st: &designcraft_doc::Story| st.tables.values().next().unwrap().cells[0].text.clone();
let mut s = crate::Session::new();
let (f, sid) = marked_frame(&mut s);
// The table's first cell holds a table.
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let inner = designcraft_doc::Table::new(d.alloc(), 1, 1, 0, 0, 40.0);
let st = d.story_mut(sid).unwrap();
let text = &mut Arc::make_mut(st.tables.values_mut().next().unwrap()).cells[0].text;
text.insert_table(text.len(), inner);
for copy_cmd in ["edit.duplicate", "edit.paste"] {
if copy_cmd == "edit.paste" {
s.execute("selection.set", &json!({"ids": [f]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
}
let copy = s.execute(copy_cmd, &json!({})).unwrap()["ids"][0].as_u64().unwrap();
let d = &s.doc().unwrap().doc;
let csid = d.item(designcraft_doc::ItemId(copy)).unwrap().text_frame().unwrap().story;
let (o, c) = (d.story(sid).unwrap(), d.story(csid).unwrap());
let (oc, cc) = (cell(o), cell(c));
let (oi, ci) = (*oc.tables.keys().next().unwrap(), *cc.tables.keys().next().unwrap());
assert_ne!(oi, ci, "{copy_cmd}: the copied cell's table has an id of its own");
assert_eq!(cc.tables[&ci].id, ci);
assert_eq!(cc.paras.iter().filter_map(|pf| pf.table).collect::<Vec<_>>(), [ci], "the cell's paragraph holds the copied table");
let mut ids = Vec::new();
for st in d.stories.values() {
table_ids(st, &mut ids);
}
let unique: std::collections::HashSet<_> = ids.iter().collect();
assert_eq!(unique.len(), ids.len(), "{copy_cmd}: no two tables share an id: {ids:?}");
}
}
#[test]
fn groups_nested_too_deeply_are_not_copied() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let frame = |s: &mut crate::Session| s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let mut g = frame(&mut s);
for _ in 0..=super::MAX_COPY_DEPTH {
let other = frame(&mut s);
g = s.execute("object.group", &json!({"ids": [g, other]})).unwrap()["id"].as_u64().unwrap();
}
let text = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "content": "text", "text": "Host"})).unwrap()["id"].as_u64();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(text.unwrap())).unwrap().text_frame().unwrap().story;
let before = s.doc().unwrap().doc.clone();
assert!(s.execute("transform.move", &json!({"dx": 10, "dy": 0, "copy": true, "ids": [g]})).is_err());
assert_eq!(s.doc().unwrap().doc, before, "nothing is copied");
// Nor pasted into text as an anchored object.
s.execute("selection.set", &json!({"ids": [g]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": 4, "focus": 4})).unwrap();
assert!(s.execute("edit.paste", &json!({})).is_err());
assert_eq!(s.doc().unwrap().doc, before, "nothing is pasted");
}
#[test]
fn tables_nested_too_deeply_in_cells_are_not_copied() {
use std::sync::Arc;
// The first cell of the story's table holds `n` tables, each in the first cell of the one before.
fn nested(n: usize) -> crate::Session {
let mut s = crate::Session::new();
let (f, sid) = marked_frame(&mut s);
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let mut chain: Option<designcraft_doc::Table> = None;
for _ in 0..n {
let mut t = designcraft_doc::Table::new(d.alloc(), 1, 1, 0, 0, 40.0);
if let Some(inner) = chain.take() {
t.cells[0].text.insert_table(0, inner);
}
chain = Some(t);
}
let st = d.story_mut(sid).unwrap();
let text = &mut Arc::make_mut(st.tables.values_mut().next().unwrap()).cells[0].text;
text.insert_table(text.len(), chain.unwrap());
s.execute("selection.set", &json!({"ids": [f]})).unwrap();
s
}
let mut s = nested(super::MAX_COPY_DEPTH);
s.execute("edit.duplicate", &json!({})).unwrap();
let mut s = nested(super::MAX_COPY_DEPTH + 1);
let before = s.doc().unwrap().doc.clone();
let e = s.execute("edit.duplicate", &json!({})).unwrap_err().to_string();
assert!(e.contains("too deeply to copy"), "{e}");
assert_eq!(s.doc().unwrap().doc, before, "nothing is copied");
s.execute("edit.copy", &json!({})).unwrap();
assert!(s.execute("edit.paste", &json!({})).is_err());
assert_eq!(s.doc().unwrap().doc, before, "nothing is pasted");
}
#[test]
fn objects_anchored_in_cells_nested_too_deeply_are_not_copied() {
use std::sync::Arc;
// The first cell of the story's table holds `n` tables, each in the first cell of the one before, and the cell
// of the innermost one holds an anchored object.
fn nested(n: usize) -> crate::Session {
let mut s = crate::Session::new();
let (f, sid) = marked_frame(&mut s);
let leaf = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap();
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let item = d.item(designcraft_doc::ItemId(leaf)).cloned().unwrap();
Arc::make_mut(&mut d.spreads[0]).items.retain(|i| i.id.0 != leaf);
let mut chain: Option<designcraft_doc::Table> = None;
for _ in 0..n {
let mut t = designcraft_doc::Table::new(d.alloc(), 1, 1, 0, 0, 40.0);
match chain.take() {
Some(inner) => {
t.cells[0].text.insert_table(0, inner);
}
None => {
t.cells[0].text.insert_object(0, designcraft_doc::AnchoredObject::new(item.clone(), Default::default()));
}
}
chain = Some(t);
}
let st = d.story_mut(sid).unwrap();
let text = &mut Arc::make_mut(st.tables.values_mut().next().unwrap()).cells[0].text;
text.insert_table(text.len(), chain.unwrap());
s.execute("selection.set", &json!({"ids": [f]})).unwrap();
s
}
let mut s = nested(super::MAX_COPY_DEPTH - 1);
s.execute("edit.duplicate", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.anchored_items().len(), 2, "the anchored object is copied");
let mut s = nested(super::MAX_COPY_DEPTH);
let before = s.doc().unwrap().doc.clone();
let e = s.execute("edit.duplicate", &json!({})).unwrap_err().to_string();
assert!(e.contains("too deeply to copy"), "{e}");
assert_eq!(s.doc().unwrap().doc, before, "nothing is copied");
}
#[test]
fn pasted_cross_references_follow_the_pasted_anchors_of_other_stories() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let frame = |s: &mut crate::Session, rect: [f64; 4], text: &str| {
let id = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text})).unwrap()["id"].as_u64().unwrap();
(id, s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().story)
};
// Each story refers to the other, so one of the two is pasted before its destination.
let (a, first) = frame(&mut s, [72.0, 72.0, 300.0, 200.0], "Methods, see ");
let (b, second) = frame(&mut s, [72.0, 300.0, 300.0, 400.0], "Results, see ");
s.execute("text.select", &json!({"story": first.0, "anchor": 13, "focus": 13})).unwrap();
s.execute("xref.insert", &json!({"paragraph": "Results"})).unwrap();
s.execute("text.select", &json!({"story": second.0, "anchor": 13, "focus": 13})).unwrap();
s.execute("xref.insert", &json!({"paragraph": "Methods"})).unwrap();
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
// Into another document, which holds no anchor of the original's: its stories get other ids.
s.execute("file.new", &json!({})).unwrap();
for _ in 0..3 {
frame(&mut s, [0.0, 0.0, 50.0, 50.0], "");
}
s.execute("selection.set", &json!({"ids": []})).unwrap();
let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect::<Vec<_>>();
let d = &s.doc().unwrap().doc;
let story = |id: u64| d.story(d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().story).unwrap();
for (refers, dest) in [(story(ids[0]), story(ids[1])), (story(ids[1]), story(ids[0]))] {
assert_eq!(refers.xrefs[0].target, dest.anchors[0].id, "the cross-reference points at the pasted destination");
assert_eq!(d.find_anchor(refers.xrefs[0].target).map(|(at, _)| at), Some(dest.id));
}
}
/// The first copy's new anchor id is the second original's anchor id, as can happen between two documents.
#[test]
fn copied_cross_references_keep_their_destination_when_new_ids_repeat_old_ones() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
s.edit(|d, _| {
d.next_id += 1000;
Ok(())
})
.unwrap();
let frame = |s: &mut crate::Session, rect: [f64; 4], text: &str| {
let id = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text})).unwrap()["id"].as_u64().unwrap();
(designcraft_doc::ItemId(id), s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().story)
};
let (a, first) = frame(&mut s, [72.0, 72.0, 300.0, 200.0], "Methods, see ");
let (b, second) = frame(&mut s, [72.0, 300.0, 300.0, 400.0], "Results");
s.execute("text.select", &json!({"story": first.0, "anchor": 13, "focus": 13})).unwrap();
s.execute("xref.insert", &json!({"paragraph": "Methods"})).unwrap();
s.execute("text.select", &json!({"story": second.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("anchor.create", &json!({"name": "Results"})).unwrap();
let src = s.doc().unwrap().doc.as_ref().clone();
assert_eq!(src.story(second).unwrap().anchors[0].id, (second.0 << 24) + 1);
// The copy of `first` gets story id `second`: one id goes to the frame, the next to its story.
let mut t = crate::Session::new();
t.execute("file.new", &json!({})).unwrap();
let mut dst = t.doc().unwrap().doc.as_ref().clone();
assert!(dst.next_id < second.0 - 2);
dst.next_id = second.0 - 2;
let ids = super::duplicate_from(&mut dst, &src, &[a, b], designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap();
let story = |id: designcraft_doc::ItemId| dst.story(dst.item(id).unwrap().text_frame().unwrap().story).unwrap();
let (one, two) = (story(ids[0]), story(ids[1]));
assert_eq!(one.id, second);
assert_eq!(one.xrefs[0].target, one.anchors[0].id, "the copy still refers to its own paragraph");
assert_ne!(two.anchors[0].id, one.anchors[0].id);
}
}
#[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_eq!(e.inner_shadow.mode, designcraft_color::BlendMode::Multiply, "a patch without a mode keeps the default");
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));
assert_eq!(og.mode, designcraft_color::BlendMode::Screen);
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);
}
/// A negative or huge inset or gutter, an inset list that isn't four sides, a column count
/// outside 1 to 40, a switch that isn't true or false, and a vertical justification or first
/// baseline that isn't one of the names were applied, clamped or skipped without a word. They
/// are refused and the frame keeps its options.
#[test]
fn text_frame_options_refuse_insets_and_names_outside_their_ranges() {
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 options = |s: &Session| s.doc().unwrap().doc.item(id).and_then(|i| i.text_frame()).map(|t| t.options.clone()).unwrap();
let before = options(&s);
for (params, says) in [
(json!({"inset": -1}), "inset must be a number of points from 0 to 15552"),
(json!({"inset": 15553}), "inset must be a number of points from 0 to 15552"),
(json!({"inset": [1, 2, 3]}), "inset must be a number or [top, left, bottom, right]"),
(json!({"inset": [1, 2, 3, -4]}), "inset must be a number of points from 0 to 15552"),
(json!({"inset": [1, "2", 3, 4]}), "inset must be a number of points from 0 to 15552"),
(json!({"inset": "6pt"}), "inset must be a number or [top, left, bottom, right]"),
(json!({"verticalJustification": "middle"}), "verticalJustification must be one of top|center|bottom|justify"),
(json!({"firstBaseline": "baseline"}), "firstBaseline must be one of"),
(json!({"autoSize": "both"}), "autoSize must be one of"),
(json!({"columnsKind": "flexible"}), "columnsKind must be one of"),
(json!({"paragraphSpacingLimit": -1}), "paragraphSpacingLimit must be a number of points from 0 to 15552"),
(json!({"firstBaselineMin": 20000}), "firstBaselineMin must be a number of points from 0 to 15552"),
(json!({"gutter": -5}), "gutter must be a number of points from 0 to 15552"),
(json!({"gutter": 1e300}), "gutter must be a number of points from 0 to 15552"),
(json!({"gutter": "12pt"}), "gutter must be a number of points from 0 to 15552"),
(json!({"columns": 99}), "columns must be a whole number from 1 to 40"),
(json!({"columns": "two"}), "columns must be a whole number from 1 to 40"),
(json!({"columns": 2.5}), "columns must be a whole number from 1 to 40"),
(json!({"balanceColumns": "yes"}), "balanceColumns must be true or false"),
(json!({"ignoreWrap": 1}), "ignoreWrap must be true or false"),
(json!({"columnRule": "on"}), "columnRule must be true or false"),
(json!({"noLineBreaks": 0}), "noLineBreaks must be true or false"),
(json!({"useMinimumHeight": "true"}), "useMinimumHeight must be true or false"),
(json!({"useMinimumWidth": []}), "useMinimumWidth must be true or false"),
(json!({"vertical": "yes"}), "vertical must be true or false"),
] {
let mut p = params.clone();
p["ids"] = json!([id.0]);
let e = s.execute("object.textFrameOptions", &p).expect_err("refused").to_string();
assert!(e.contains(says), "{params}: {e}");
assert_eq!(options(&s), before, "{params}");
}
// Values in range apply, the ends included, and null keeps a side.
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "inset": [0, null, 15552, 4.5], "verticalJustification": "bottom"})).unwrap();
let o = options(&s);
assert_eq!(o.inset, [0.0, before.inset[1], 15552.0, 4.5]);
assert_eq!(o.vertical_justification, designcraft_doc::VerticalJustification::Bottom);
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "inset": 6, "firstBaseline": "capHeight"})).unwrap();
let o = options(&s);
assert_eq!(o.inset, [6.0; 4]);
assert_eq!(o.first_baseline, designcraft_doc::FirstBaseline::CapHeight);
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": 3, "gutter": 15552, "balanceColumns": true, "ignoreWrap": true}))
.unwrap();
let o = options(&s);
assert_eq!((o.columns, o.gutter, o.balance_columns, o.ignore_wrap), (3, 15552.0, true, true));
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "gutter": 0, "balanceColumns": false})).unwrap();
let o = options(&s);
assert_eq!((o.gutter, o.balance_columns), (0.0, false));
}
}
#[cfg(test)]
mod anchored_copy_tests {
use super::*;
use designcraft_doc::AnchoredObject;
/// A text frame with an inline rectangle; returns the frame and the anchored item's id.
fn frame_with_inline_box(s: &mut Session) -> (ItemId, ItemId) {
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Before after."})).unwrap();
let frame = ItemId(r["id"].as_u64().unwrap());
let sid = s.doc().unwrap().doc.item(frame).unwrap().text_frame().unwrap().story;
let b = s.execute("frame.create", &json!({"rect": [450, 450, 474, 490]})).unwrap();
s.execute("anchored.insert", &json!({"ids": [b["id"]], "story": sid.0, "pos": 7})).unwrap();
(frame, s.doc().unwrap().doc.story(sid).unwrap().objects[0].item.id)
}
fn assert_reopens(doc: &Document) {
doc.check().unwrap();
designcraft_format::load(&designcraft_format::save(doc).unwrap()).unwrap();
}
// #1522: the copy kept the anchored object's id, and the saved file no longer opened.
#[test]
fn duplicating_a_frame_with_an_anchored_object_saves_a_file_that_reopens() {
let mut s = Session::new();
let (frame, anchored) = frame_with_inline_box(&mut s);
s.execute("transform.move", &json!({"ids": [frame.0], "dx": 10, "dy": 10, "copy": true})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.anchored_items().iter().filter(|(_, i)| i.id == anchored).count(), 1);
assert_eq!(doc.anchored_items().len(), 2);
assert_reopens(doc);
}
#[test]
fn pasting_a_frame_with_an_anchored_object_twice_keeps_ids_unique() {
let mut s = Session::new();
let (frame, _) = frame_with_inline_box(&mut s);
s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.anchored_items().len(), 3);
assert_reopens(doc);
}
// IDML import can anchor a text frame; its story is copied too, with the copy in its thread.
#[test]
fn an_anchored_text_frame_gets_its_own_story_copy() {
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
let layer = d.default_layer();
let (host, host_sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), layer, "Host", ParaFormat::default()).unwrap();
let (inner, inner_sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 50.0, 50.0), layer, "Inner", ParaFormat::default()).unwrap();
let item = d.item(inner).unwrap().clone();
let inner_story = d.stories.get(&inner_sid).unwrap().clone();
d.remove_item(inner).unwrap();
d.stories.insert(inner_sid, inner_story);
d.story_mut(host_sid).unwrap().insert_object(0, AnchoredObject { item, position: Default::default() });
d.check().unwrap();
let src = d.clone();
let copy = duplicate_from(&mut d, &src, &[host], SpreadRef::Doc(0), Vec2::new(10.0, 10.0)).unwrap();
let copy_sid = d.item(copy[0]).unwrap().text_frame().unwrap().story;
let anchored = &d.story(copy_sid).unwrap().objects[0].item;
assert_ne!(anchored.id, inner);
let anchored_sid = anchored.text_frame().unwrap().story;
assert_ne!(anchored_sid, inner_sid);
assert_eq!(d.story(anchored_sid).unwrap().frames, [anchored.id]);
assert_eq!(d.story(inner_sid).unwrap().frames, [inner]);
assert_reopens(&d);
}
// Each story refers to an anchor in the story anchored in it, which is copied after it.
#[test]
fn cross_references_into_anchored_stories_follow_their_copies() {
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
let layer = d.default_layer();
let mut frame =
|text: &str| d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 50.0, 50.0), layer, text, ParaFormat::default()).unwrap();
let (host, host_sid) = frame("Host ");
let (inner, inner_sid) = frame("Inner ");
let (deep, deep_sid) = frame("Deep");
for (into, (id, sid)) in [(inner_sid, (deep, deep_sid)), (host_sid, (inner, inner_sid))] {
let target = d.story_mut(sid).unwrap().insert_anchor(0, "Here");
let st = d.story_mut(into).unwrap();
st.insert_xref(st.len(), designcraft_doc::CrossRef { target, ..Default::default() });
let item = d.item(id).unwrap().clone();
let story = d.stories.get(&sid).unwrap().clone();
d.remove_item(id).unwrap();
d.stories.insert(sid, story);
d.story_mut(into).unwrap().insert_object(0, AnchoredObject { item, position: Default::default() });
}
d.check().unwrap();
let src = d.clone();
let copy = duplicate_from(&mut d, &src, &[host], SpreadRef::Doc(0), Vec2::new(10.0, 10.0)).unwrap();
let anchored = |sid: StoryId| d.story(sid).unwrap().objects[0].item.text_frame().unwrap().story;
let host_copy = d.item(copy[0]).unwrap().text_frame().unwrap().story;
let inner_copy = anchored(host_copy);
let deep_copy = anchored(inner_copy);
for (refers, dest) in [(host_copy, inner_copy), (inner_copy, deep_copy)] {
let target = d.story(refers).unwrap().xrefs[0].target;
assert_eq!(target, d.story(dest).unwrap().anchors[0].id, "the copied cross-reference points at the copied anchor");
assert_eq!(d.find_anchor(target).map(|(at, _)| at), Some(dest));
}
assert_reopens(&d);
}
}