Files
wordcraft/crates/engine/src/cmd/canvas.rs
T
6b01aa967f Shape fills: gradients and transparency (#1500) (#1605)
* #1500: shape fills keep gradients and transparency (model, DOCX read/write)

A shape's, text box's and canvas's fill is now a `Fill` (solid with
transparency, or a gradient kept as the file states it) instead of one
opaque colour, so gradients and transparency survive opening and saving.

- doc: `fill::Fill`/`Gradient` with sanitizing for hostile input; `.json`
  keeps writing opaque solid fills as `[r,g,b]` and reads both forms;
  `ShapeExtra::outline_fill` keeps an outline gradient.
- docx read: `a:solidFill` with `a:alpha`, `a:gradFill` (stops, `a:lin`,
  `a:path`/`fillToRect`, `tileRect`, `rotWithShape`, `flip`), theme fill
  styles through `a:fillRef` 1-999/1001+ with `phClr`, `a:grpFill`,
  `satMod`, outline gradients, VML `v:fill` gradients and opacity, canvas
  backgrounds.
- docx write: `a:solidFill` + `a:alpha`, `a:gradFill` as read.
- engine/docbin/layout: call sites; drawing still uses the main colour
  (next commit draws gradients).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #1500: draw shape gradients and transparency (layout, render, PDF)

- layout: WordArt's paint becomes a shared `display::Paint` (solid,
  linear, radial); `paint_for` lays a fill over the shape's frame
  (linear at its angle, `scaled` honoured; path gradients circular from
  their focus to the farthest corner). Shapes, freeforms and canvas
  backgrounds use it.
- render: one `set_paint` for shapes, paths and WordArt; radial
  gradients; soft edges fade the real fill.
- pdf: axial and radial shadings with per-stop opacity.
- Review fixes: nested groups without a fill pass theirs on to
  `a:grpFill`, text-box contents don't take a group's fill, `phClr`
  transparency only on `phClr` colours, VML focus ends consistent and
  opacity clamped, `rotWithShape`/`scaled` written only when set,
  Shape Outline clears an outline gradient, shared stop sanitizing.
- docs: file-format, layout parity, gaps #1, roadmap progress log.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #1500: shape.fill sets transparency and gradients on every selected shape

- shape.fill (now cmd/shape_fill.rs) takes {"color", "transparency"?} or {"gradient": {stops,
  type, angle, focus, rotateWithShape}}; a colour alone keeps the transparency; values are
  sanitized; applies to every selected shape, text box and canvas as one undo step.
- Formatting (shape.fill, shape.effects) keeps a Shift+click multi-selection, as in Word.
- view.formatShapePane toggles the Format Shape pane.
- Session::undo_mark/merge_undo_since: a pane drag that applies as it goes undoes in one step.
- Helpers for the pane: params(fill), stop add/remove/move, directions, variations and
  presets (variation and preset colours provisional until compared with Word).
- Catalog: Shape Fill Gradient, Format Shape Pane.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #1500: Shape Fill › Gradient and the Format Shape pane

- Shape Fill › Gradient: No Gradient, Light and Dark Variations of the shape's colour, More
  Gradients… (opens the pane).
- Format Shape pane (right): No fill / Solid fill (colour, transparency) / Gradient fill
  (presets, type, direction, angle, stops bar with add/remove/drag and arrow keys, per-stop
  colour, position, transparency, rotate with shape). Drags preview live, one undo step.
- Right-click › Format Shape… on shapes, text boxes and canvases.
- Gradient swatches have accessible names; new labels translated (own wording, clean room).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #1500: preset gradients are our own design (clean room)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #1500: Gradient variations, starting gradient and directions as Word 365 does them

Observed in Word 365 (behaviour and values only):
- Shape Fill › Gradient: a No Gradient swatch, then 13 Light and 13 Dark Variations in a
  grid five wide, in Word's order; light stops tinted 66/44.5/23.5% with saturation ×1.6,
  dark shaded 30/67.5/100% with ×1.15; linear ones scaled; corners with their tile rectangle.
- Format Shape › Gradient fill starts from the theme's first accent (four stops, 0/74/83/100%).
- Corner directions in the pane get the same tile rectangle; Add/Remove sit by the stops bar.
- shape.fill carries "scaled" and "tile"; docx exports color_modified (lumMod, lumOff, tint,
  shade, satMod) so variations use the reader's own colour maths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #1500: review fixes for the shape fill ribbon controls

- Gradient variations are built from the colour the shown variation came from, so picking
  variations in turn, or No Gradient, doesn't drift the colour.
- A drag's undo mark is forgotten while the Format Shape pane is closed (a stale mark could
  merge later undo steps).
- Dragging on the stops bar away from a handle no longer moves the selected stop; the arrow
  keys are taken by the bar (no focus moves or shape nudges).
- Direction and Type changes keep a linear gradient's "scaled" and an imported tile
  rectangle; only a corner direction sets its tile.
- Colour modifiers (lumMod, lumOff, tint, shade, satMod) live on Rgb in the doc crate, shared
  by the DOCX reader and the gradient builders.
- Previews build their stop list once per swatch, not per vertex.
- Translations for the pane's hint, direction and preset names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #1500: adapt to main: caret contrast samples gradient fills; upstream tests use Fill/Paint

- Paint::at gives a paint's colour and opacity at a point; the caret's background sampling
  (#299) uses it for shape fills, now gradients or transparent, and the Format Shape
  previews use it too.
- Upstream tests (#886 shapes survive saving, render) build fills as Fill/Paint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #1500: adapt to main after the rebase: VML presets, rounded corners, new catalogs

- vml_fill takes the shape's shapetype: a VML preset shape (#1377) takes its fill, opacity and
  gradient from its shapetype when it states none of its own, as it already took its colour.
- Rounded-rectangle corners (#1476) and the gradient paints meet in render, PDF and the caret's
  background sampling; ShapeExtra keeps both `corner` and `outline_fill`.
- Tests that landed on main since build fills as Fill/Paint (VML presets, cover pages, VML
  freeforms, path arrowheads, PDF and render shapes).
- The 40 Shape Fill / Format Shape strings in the hr, ko, sv and vi catalogs, which main
  gained since.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz

* #1500: docs: shape gradients and transparency, Shape Fill › Gradient and the Format Shape pane

Re-applied on main's docs after the rebase: progress log, file-format, layout and UI parity
rows, gaps #1, status lines and revision history; parity checklist regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz

* #1500: a theme picture fill falls back on the reference colour; VML freeform opacity note

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz

* #1500: adapt to main after the second rebase: VML shapetype tests compare Fill; parity checklist regenerated

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz

* #1500: one VML fraction parser; a preset takes its gradient from its shapetype

Review notes: story.rs's watermark/freeform opacity reuses shape_style's vml_fraction (clamped at the callers) instead of a second copy; a test pins a VML preset whose v:fill gradient comes from its v:shapetype, and the 65536ths form of a fraction.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz

* #1500: adapt to main after the third rebase: the shape duplicate preview test fills with Fill

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz

* #1500: review — harness test: right-click a shape, Format Shape… opens the pane and keeps the shape selected

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr

* #1500: review — theme keeps every fill style a fillRef can reach (999 per list), still bounded

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
2026-10-21 19:12:26 -04:00

331 lines
15 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.
//! Drawing Canvas (#344): Insert › Illustrations › Shapes › New Drawing Canvas.
//!
//! A canvas is an [`InlineObject::Group`] with a [`CanvasStyle`]: a frame with its own fill and
//! outline that holds pictures, shapes and text boxes at offsets in the canvas (points from its
//! top-left corner, unscaled). While a canvas is selected, Insert › Pictures, Shapes and Text Box
//! put the new object inside it ([`add_member`]). Fit, Expand and Scale Drawing change its frame;
//! Shape Fill and Shape Outline colour its background and border.
use serde_json::{Value, json};
use wordcraft_doc::para::{CanvasStyle, Float, GroupChild, InlineObject, MAX_GROUP_CHILDREN};
use wordcraft_doc::{PartKind, Pos, StoryRef};
use super::objects::object_selection;
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// Largest canvas side and member offset, points (well past any page).
const MAX_SIDE: f32 = 4000.0;
/// Smallest canvas side, points.
const MIN_SIDE: f32 = 9.0;
/// Room Fit leaves around the members, points (none: Word's Fit hugs the drawing).
const FIT_MARGIN: f32 = 0.0;
fn has_canvas(s: &Session) -> Option<&'static str> {
if selected_canvas(s).is_some() { None } else { Some("select a drawing canvas first") }
}
/// The selected Drawing Canvas: its position and the canvas.
pub fn selected_canvas(s: &Session) -> Option<(Pos, &InlineObject)> {
object_selection(s).filter(|(_, o)| o.is_canvas())
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.canvas", "Drawing Canvas", "Insert › Illustrations", insert)
.params(r#"{"width"?: pt (default 432 = 6"), "height"?: pt (default 216 = 3"), "fill"?: "RRGGBB", "outline"?: "RRGGBB"} (in line with text at the caret; it stays selected, so the next Pictures, Shapes or Text Box goes inside it)"#)
.when(|s| {
matches!(s.sel.focus.story, StoryRef::Part(id) if s.doc.parts.get(&id).is_some_and(|p| p.kind == PartKind::TextBox))
.then_some("a drawing canvas can't go inside a text box")
}),
CommandSpec::new("canvas.fit", "Fit", "Shape Format › Drawing Canvas", fit)
.params(r#"{} (shrinks the selected canvas's frame to its drawing: the members move to its top-left corner)"#)
.when(has_canvas),
CommandSpec::new("canvas.expand", "Expand", "Shape Format › Drawing Canvas", expand)
.params(r#"{"by"?: pt (default 36)} (grows the frame right and down; at least enough to show every member)"#)
.when(has_canvas),
CommandSpec::new("canvas.scale", "Scale Drawing", "Shape Format › Drawing Canvas", scale)
.params(r#"{"factor": number (0.1–10)} (scales the frame and every member together)"#)
.when(has_canvas),
CommandSpec::new("canvas.member", "Canvas Member", "Shape Format › Drawing Canvas", member)
.params(r#"{"index": n, "x"?: pt, "y"?: pt, "width"?: pt, "height"?: pt, "delete"?: bool} (moves, resizes or removes member `index` of the selected canvas; x/y from the canvas's top-left; with no change, returns the members)"#)
.when(has_canvas),
]
}
/// `insert.canvas`: a new empty canvas in line with text at the caret, selected.
fn insert(s: &mut Session, v: &Value) -> CmdResult {
let w = p::f32(v, "width").unwrap_or(432.0).clamp(MIN_SIDE, MAX_SIDE);
let h = p::f32(v, "height").unwrap_or(216.0).clamp(MIN_SIDE, MAX_SIDE);
let style = CanvasStyle {
fill: p::str(v, "fill").and_then(wordcraft_doc::props::Rgb::parse).map(wordcraft_doc::fill::Fill::solid),
stroke: p::str(v, "outline").and_then(wordcraft_doc::props::Rgb::parse),
stroke_width: 0.75,
};
let props = s.typing_props();
let at = delete_selection(s)?;
let obj = InlineObject::Group { w, h, float: Float::default(), ch_w: w, ch_h: h, children: Vec::new(), canvas: Some(style) };
let end = s.doc.insert_object(&at, obj, &props)?;
s.sel = Selection { anchor: at, focus: end };
sel_result(s)
}
/// With a Drawing Canvas selected, put `obj` inside it rather than at the caret: centred, each
/// later one a little lower and to the right, shrunk (keeping its proportions) to fit. Returns
/// the member's index, or `None` when no canvas is selected (insert as usual).
pub fn add_member(s: &mut Session, obj: InlineObject) -> Result<Option<usize>, CmdError> {
let Some((pos, canvas)) = selected_canvas(s) else { return Ok(None) };
let InlineObject::Group { w: cw, h: ch, children, .. } = canvas else { return Ok(None) };
if children.len() >= MAX_GROUP_CHILDREN {
return Err(CmdError::Failed("the drawing canvas is full".into()));
}
let (cw, ch, n) = (*cw, *ch, children.len());
let mut obj = obj;
let (ow, oh, _) = obj.frame().ok_or_else(|| CmdError::Params("not a picture or shape".into()))?;
let k = (cw / ow.max(1.0)).min(ch / oh.max(1.0)).min(1.0);
let (ow, oh) = ((ow * k).max(1.0), (oh * k).max(1.0));
obj.set_size(ow, oh);
if let Some(f) = obj.float_mut() {
*f = Float::default();
}
let step = 12.0 * (n % 6) as f32;
let x = ((cw - ow) / 2.0 + step).clamp(0.0, (cw - ow).max(0.0));
let y = ((ch - oh) / 2.0 + step).clamp(0.0, (ch - oh).max(0.0));
edit_canvas(s, &pos, |_, _, children| children.push(GroupChild { x, y, obj: obj.clone() }))?;
Ok(Some(n))
}
/// Change the canvas at `pos`: `f` gets its frame size and members (and may change them).
fn edit_canvas(s: &mut Session, pos: &Pos, f: impl FnOnce(&mut f32, &mut f32, &mut Vec<GroupChild>)) -> Result<InlineObject, CmdError> {
let para = s.doc.para_mut(pos.story, &pos.path)?;
let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
let InlineObject::Group { w, h, ch_w, ch_h, children, canvas: Some(_), .. } = o else {
return Err(CmdError::Disabled("select a drawing canvas first".into()));
};
f(w, h, children);
*w = clean(*w).clamp(MIN_SIDE, MAX_SIDE);
*h = clean(*h).clamp(MIN_SIDE, MAX_SIDE);
(*ch_w, *ch_h) = (*w, *h);
let o = o.clone();
para.touch();
s.touch();
Ok(o)
}
fn clean(v: f32) -> f32 {
if v.is_finite() { v } else { 0.0 }
}
fn canvas_pos(s: &Session) -> Result<Pos, CmdError> {
selected_canvas(s).map(|(p, _)| p).ok_or_else(|| CmdError::Disabled("select a drawing canvas first".into()))
}
fn result(o: &InlineObject) -> CmdResult {
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
}
/// The members' bounds (x0, y0, x1, y1) in the canvas, or `None` when it is empty.
fn bounds(children: &[GroupChild]) -> Option<[f32; 4]> {
children
.iter()
.filter_map(|c| c.obj.frame().map(|(w, h, _)| [clean(c.x), clean(c.y), clean(c.x) + clean(w), clean(c.y) + clean(h)]))
.reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])
}
/// `canvas.fit`: the frame shrinks (or grows) to the drawing, which moves to its corner.
fn fit(s: &mut Session, _: &Value) -> CmdResult {
let pos = canvas_pos(s)?;
let o = edit_canvas(s, &pos, |w, h, children| {
let Some([x0, y0, x1, y1]) = bounds(children) else { return };
for c in children.iter_mut() {
c.x = (c.x - x0 + FIT_MARGIN).clamp(-MAX_SIDE, MAX_SIDE);
c.y = (c.y - y0 + FIT_MARGIN).clamp(-MAX_SIDE, MAX_SIDE);
}
*w = x1 - x0 + 2.0 * FIT_MARGIN;
*h = y1 - y0 + 2.0 * FIT_MARGIN;
})?;
result(&o)
}
/// `canvas.expand`: more room to the right and below, at least enough for every member.
fn expand(s: &mut Session, v: &Value) -> CmdResult {
let by = p::f32(v, "by").unwrap_or(36.0).clamp(0.0, MAX_SIDE);
let pos = canvas_pos(s)?;
let o = edit_canvas(s, &pos, |w, h, children| {
let [_, _, x1, y1] = bounds(children).unwrap_or([0.0; 4]);
*w = (*w + by).max(x1);
*h = (*h + by).max(y1);
})?;
result(&o)
}
/// `canvas.scale`: the frame and the drawing scale together.
fn scale(s: &mut Session, v: &Value) -> CmdResult {
let k = p::req_f32(v, "factor")?;
if !(0.1..=10.0).contains(&k) {
return Err(CmdError::Params("`factor` must be between 0.1 and 10".into()));
}
let pos = canvas_pos(s)?;
let o = edit_canvas(s, &pos, |w, h, children| {
*w *= k;
*h *= k;
for c in children.iter_mut() {
c.x = (c.x * k).clamp(-MAX_SIDE, MAX_SIDE);
c.y = (c.y * k).clamp(-MAX_SIDE, MAX_SIDE);
if let Some((mw, mh, _)) = c.obj.frame() {
c.obj.set_size((mw * k).clamp(1.0, MAX_SIDE), (mh * k).clamp(1.0, MAX_SIDE));
}
}
})?;
result(&o)
}
/// `canvas.member`: move, resize or remove one member; with no change, list the members.
fn member(s: &mut Session, v: &Value) -> CmdResult {
let pos = canvas_pos(s)?;
let Some(i) = p::u64(v, "index").and_then(|i| usize::try_from(i).ok()) else {
let Some((_, InlineObject::Group { children, .. })) = selected_canvas(s) else {
return Err(CmdError::Disabled("select a drawing canvas first".into()));
};
let list: Vec<Value> = children
.iter()
.enumerate()
.map(|(i, c)| {
let (w, h) = c.obj.frame().map(|(w, h, _)| (w, h)).unwrap_or((0.0, 0.0));
json!({"index": i, "x": c.x, "y": c.y, "width": w, "height": h})
})
.collect();
return Ok(json!({"members": list}));
};
let count = match selected_canvas(s) {
Some((_, InlineObject::Group { children, .. })) => children.len(),
_ => 0,
};
if i >= count {
return Err(CmdError::Params(format!("no member {i} (the canvas has {count})")));
}
let (x, y, nw, nh) = (p::f32(v, "x"), p::f32(v, "y"), p::f32(v, "width"), p::f32(v, "height"));
let delete = p::bool(v, "delete").unwrap_or(false);
let o = edit_canvas(s, &pos, |_, _, children| {
if delete {
if i < children.len() {
children.remove(i);
}
return;
}
let Some(c) = children.get_mut(i) else { return };
if let Some(x) = x {
c.x = x.clamp(-MAX_SIDE, MAX_SIDE);
}
if let Some(y) = y {
c.y = y.clamp(-MAX_SIDE, MAX_SIDE);
}
if let Some((w0, h0, _)) = c.obj.frame() {
c.obj.set_size(nw.unwrap_or(w0).clamp(1.0, MAX_SIDE), nh.unwrap_or(h0).clamp(1.0, MAX_SIDE));
}
})?;
result(&o)
}
/// Shape Fill and Shape Outline on a selected canvas colour its background and border.
pub fn style_mut(o: &mut InlineObject) -> Option<&mut CanvasStyle> {
match o {
InlineObject::Group { canvas: Some(c), .. } => Some(c),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn canvas(s: &Session) -> InlineObject {
selected_canvas(s).map(|(_, o)| o.clone()).unwrap()
}
fn members(o: &InlineObject) -> Vec<(f32, f32, f32, f32)> {
let InlineObject::Group { children, .. } = o else { panic!("{o:?}") };
children
.iter()
.map(|c| {
let (w, h, _) = c.obj.frame().unwrap();
(c.x, c.y, w, h)
})
.collect()
}
/// Insert › Drawing Canvas, then Shapes and Text Box: the new objects go inside the
/// selected canvas, not into the text, and the canvas's frame keeps its size (#344).
#[test]
fn shapes_inserted_with_a_canvas_selected_go_inside_it() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "Before "})).unwrap();
s.run("insert.canvas", &json!({})).unwrap();
let c = canvas(&s);
assert_eq!(c.frame().map(|(w, h, _)| (w, h)), Some((432.0, 216.0)));
s.run("insert.shape", &json!({"kind": "ellipse", "width": 100, "height": 50})).unwrap();
s.run("insert.shape", &json!({"kind": "star", "width": 60, "height": 60})).unwrap();
let c = canvas(&s);
let m = members(&c);
assert_eq!(m.len(), 2);
assert_eq!(m[0], (166.0, 83.0, 100.0, 50.0), "centred in the canvas");
assert_eq!((m[1].2, m[1].3), (60.0, 60.0));
// Only the canvas sits in the text.
let para = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
assert_eq!(para.objects.len(), 1);
// Fill and outline colour the canvas.
s.run("shape.fill", &json!({"color": "FFF2CC"})).unwrap();
s.run("shape.outline", &json!({"color": "1F4E79", "width": 1.5})).unwrap();
let style = canvas(&s).canvas_style().unwrap().clone();
assert_eq!(
style,
CanvasStyle {
fill: wordcraft_doc::props::Rgb::parse("FFF2CC").map(wordcraft_doc::fill::Fill::solid),
stroke: wordcraft_doc::props::Rgb::parse("1F4E79"),
stroke_width: 1.5
}
);
// Members move, resize and go within the canvas.
s.run("canvas.member", &json!({"index": 1, "x": 10, "y": 20, "width": 30})).unwrap();
assert_eq!(members(&canvas(&s))[1], (10.0, 20.0, 30.0, 60.0));
// A text box goes in too, with its own story.
s.run("select.objects", &json!({"index": 0})).unwrap();
s.run("insert.textBox", &json!({"text": "Label"})).unwrap();
let para = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
assert_eq!(para.objects.len(), 1);
assert_eq!(para.objects[0].text_boxes().len(), 1);
// Laid out: the background, then the members clipped to the frame.
let pages = s.layout().pages.clone();
assert!(!pages.is_empty());
}
/// Fit shrinks the frame to the drawing and moves the drawing to its corner; Expand grows it;
/// Scale Drawing scales frame and members together.
#[test]
fn fit_expand_and_scale_change_the_frame() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
s.run("canvas.member", &json!({"index": 0, "x": 40, "y": 30})).unwrap();
s.run("canvas.member", &json!({"index": 1, "x": 200, "y": 100})).unwrap();
s.run("canvas.fit", &json!({})).unwrap();
let c = canvas(&s);
assert_eq!(c.frame().map(|(w, h, _)| (w, h)), Some((210.0, 120.0)));
assert_eq!(members(&c), [(0.0, 0.0, 100.0, 40.0), (160.0, 70.0, 50.0, 50.0)]);
s.run("canvas.expand", &json!({"by": 20})).unwrap();
assert_eq!(canvas(&s).frame().map(|(w, h, _)| (w, h)), Some((230.0, 140.0)));
s.run("canvas.scale", &json!({"factor": 0.5})).unwrap();
let c = canvas(&s);
assert_eq!(c.frame().map(|(w, h, _)| (w, h)), Some((115.0, 70.0)));
assert_eq!(members(&c)[1], (80.0, 35.0, 25.0, 25.0));
assert!(s.run("canvas.scale", &json!({"factor": 0.0})).is_err());
// Undo puts the drawing back as it was before the scale.
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(canvas_in_text(&s).frame().map(|(w, h, _)| (w, h)), Some((230.0, 140.0)));
}
fn canvas_in_text(s: &Session) -> InlineObject {
s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()).cloned().unwrap()
}
}