Files
wordcraft/crates/engine/src/cmd/objects.rs
T
29e978d6d8 Re-land #264, #263, #270, #271, #267, #280: clipboard pane, table styles, Manage Styles, Asian Typography, Group, Shape Effects (#323)
The original squash merges of these six PRs were empty. Integrated and tested together on current main.

Co-authored-by: Valentin Klein <vangel1976@googlemail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:09:04 -04:00

1242 lines
58 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.
//! Picture Format, Shape Format and Arrange: commands on the selected inline object.
//!
//! The selected object is the first picture/shape inside the selection (or right before the
//! caret). Picture adjustments re-encode the bitmap (originals are kept for Reset Picture).
use serde_json::{Value, json};
use wordcraft_doc::para::{Float, InlineObject, Wrap};
use wordcraft_doc::props::Rgb;
use wordcraft_doc::{Pos, StoryRef};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
fn has_picture(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Image { .. })) => None,
_ => Some("select a picture first"),
}
}
fn has_object(s: &Session) -> Option<&'static str> {
if selected(s).is_some() { None } else { Some("select a picture or shape first") }
}
fn has_floating(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
Some((_, o)) if o.is_floating() => None,
_ => Some("select a floating picture or shape first"),
}
}
/// Charts and diagrams are not written back on save yet, so their size, position and wrapping stay
/// as imported: they can be selected and deleted, not moved, resized or arranged.
const FROZEN: &str = "charts and diagrams can't be moved or resized yet";
/// `has_object` for the geometry commands: a chart or diagram is frozen.
fn has_movable(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
_ => has_object(s),
}
}
fn has_group(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Group { .. })) => None,
_ => Some("select a group first"),
}
}
fn can_group(s: &Session) -> Option<&'static str> {
if picked_objects(s).len() >= 2 { None } else { Some("select two or more pictures or shapes first (Shift+click)") }
}
fn has_shape(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Shape { .. })) => None,
_ => Some("select a shape first"),
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("picture.size", "Size", "Picture Format › Size", size).params(r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent}"#).when(has_movable),
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", |s, v| {
let c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.45));
with_obj(s, |o| {
if let InlineObject::Image { crop, .. } = o {
*crop = c;
}
})
})
.params(r#"{"left"?, "top"?, "right"?, "bottom"? (fractions 0–0.45)}"#)
.when(has_picture),
CommandSpec::new("picture.altText", "Alt Text", "Picture Format › Accessibility", |s, v| {
let t = p::req_str(v, "text")?.to_string();
with_obj(s, |o| {
if let InlineObject::Image { alt, .. } = o {
*alt = t.clone();
}
})
})
.params(r#"{"text": string}"#)
.when(has_picture),
CommandSpec::new("picture.corrections", "Corrections", "Picture Format › Adjust", |s, v| {
let b = p::f32(v, "brightness").unwrap_or(0.0).clamp(-100.0, 100.0);
let c = p::f32(v, "contrast").unwrap_or(0.0).clamp(-100.0, 100.0);
let sharpen = p::f32(v, "sharpen").unwrap_or(0.0).clamp(-100.0, 100.0);
adjust(s, move |img| {
let mut img = img;
if b != 0.0 {
img = image::DynamicImage::ImageRgba8(image::imageops::colorops::brighten(&img, (b * 2.55) as i32));
}
if c != 0.0 {
img = image::DynamicImage::ImageRgba8(image::imageops::colorops::contrast(&img, c));
}
if sharpen > 0.0 {
img = img.unsharpen(1.0 + sharpen / 50.0, 2);
} else if sharpen < 0.0 {
img = img.blur(-sharpen / 25.0);
}
img
})
})
.params(r#"{"brightness"?: -100..100, "contrast"?: -100..100, "sharpen"?: -100..100}"#)
.when(has_picture),
CommandSpec::new("picture.color", "Color", "Picture Format › Adjust", |s, v| {
let mode = p::str(v, "mode").unwrap_or("grayscale").to_string();
let sat = p::f32(v, "saturation").unwrap_or(100.0);
adjust(s, move |img| recolor(img, &mode, sat))
})
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
.when(has_picture),
CommandSpec::new("picture.effects", "Artistic Effects", "Picture Format › Adjust", |s, v| {
let e = p::str(v, "effect").unwrap_or("blur").to_string();
adjust(s, move |img| match e.as_str() {
"blur" => img.blur(3.0),
"sharpen" => img.unsharpen(2.0, 2),
"invert" => {
let mut i = img;
i.invert();
i
}
"posterize" => {
let mut rgba = img.to_rgba8();
for px in rgba.pixels_mut() {
for c in px.0.iter_mut().take(3) {
*c = (*c / 64) * 85;
}
}
image::DynamicImage::ImageRgba8(rgba)
}
"pixelate" => {
let (w, h) = (img.width().max(1), img.height().max(1));
img.resize_exact((w / 12).max(1), (h / 12).max(1), image::imageops::FilterType::Nearest).resize_exact(w, h, image::imageops::FilterType::Nearest)
}
_ => img,
})
})
.params(r#"{"effect": "blur|sharpen|invert|posterize|pixelate"}"#)
.when(has_picture),
CommandSpec::new("picture.transparency", "Transparency", "Picture Format › Adjust", |s, v| {
let t = p::f32(v, "percent").unwrap_or(50.0).clamp(0.0, 100.0);
adjust(s, move |img| {
let mut rgba = img.to_rgba8();
for px in rgba.pixels_mut() {
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
}
image::DynamicImage::ImageRgba8(rgba)
})
})
.params(r#"{"percent": 0..100}"#)
.when(has_picture),
CommandSpec::new("picture.removeBackground", "Remove Background", "Picture Format › Adjust", |s, v| {
let tol = p::f32(v, "tolerance").unwrap_or(30.0).clamp(0.0, 255.0);
adjust(s, move |img| remove_background(img, tol))
})
.params(r#"{"tolerance"?: 0..255}"#)
.when(has_picture),
CommandSpec::new("picture.compress", "Compress Pictures", "Picture Format › Adjust", |s, v| {
let max = p::u64(v, "maxPixels").unwrap_or(1600).clamp(32, 20000) as u32;
adjust(s, move |img| if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img })
})
.params(r#"{"maxPixels"?: n}"#)
.when(has_picture),
CommandSpec::new("picture.reset", "Reset Picture", "Picture Format › Adjust", reset).when(has_picture),
CommandSpec::new("picture.change", "Change Picture", "Picture Format › Adjust", |s, v| {
let bytes = if let Some(d) = p::str(v, "data") {
super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64".into()))?
} else if let Some(path) = p::str(v, "path") {
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
return Err(CmdError::Failed("paths aren't available on the web".into()));
}
} else {
return Err(CmdError::Params("`path` or `data` required".into()));
};
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image".into()))?;
let key = s.doc.add_media(bytes, "png");
with_obj(s, |o| {
if let InlineObject::Image { media, .. } = o {
*media = key.clone();
}
})
})
.params(r#"{"path"?: string, "data"?: base64}"#)
.when(has_picture),
CommandSpec::new("picture.style", "Picture Styles", "Picture Format › Picture Styles", |s, v| {
let style = p::str(v, "style").unwrap_or("simpleFrame").to_string();
adjust(s, move |img| picture_style(img, &style))
})
.params(r#"{"style": "simpleFrame|thickFrame|rounded|softEdge|shadow"}"#)
.when(has_picture),
CommandSpec::new("picture.border", "Picture Border", "Picture Format › Picture Styles", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse).unwrap_or(Rgb::BLACK);
let w = p::u64(v, "width").unwrap_or(4).clamp(1, 200) as u32;
adjust(s, move |img| frame(img, w, [c.0, c.1, c.2, 255]))
})
.params(r#"{"color"?: "RRGGBB", "width"?: px}"#)
.when(has_picture),
CommandSpec::new("arrange.rotate", "Rotate", "Layout › Arrange", |s, v| {
let how = p::str(v, "direction").unwrap_or("right").to_string();
let swap = how == "right" || how == "left";
let r = adjust(s, move |img| match how.as_str() {
"left" => img.rotate270(),
"flipH" => img.fliph(),
"flipV" => img.flipv(),
_ => img.rotate90(),
})?;
if swap {
with_obj(s, |o| {
if let InlineObject::Image { w, h, .. } = o {
std::mem::swap(w, h);
}
})?;
}
Ok(r)
})
.params(r#"{"direction": "right|left|flipH|flipV"}"#)
.when(has_picture),
CommandSpec::new("arrange.wrap", "Wrap Text", "Layout › Arrange", |s, v| {
let wrap: Wrap = serde_json::from_value(v.get("wrap").cloned().unwrap_or(json!("square"))).map_err(|e| CmdError::Params(e.to_string()))?;
with_float(s, |f| f.wrap = wrap)
})
.params(r#"{"wrap": "inline|square|tight|through|topAndBottom|behindText|inFrontOfText"}"#)
.when(has_movable),
CommandSpec::new("arrange.position", "Position", "Layout › Arrange", |s, v| {
let preset = p::str(v, "preset").unwrap_or("middleCenter").to_string();
let (x, y) = (p::f32(v, "x"), p::f32(v, "y"));
let sect = super::page::sect(s);
let size = selected(s).map(|(_, o)| obj_size(&o)).unwrap_or((72.0, 72.0));
with_float(s, |f| {
if f.wrap == Wrap::Inline {
f.wrap = Wrap::Square;
}
f.h_rel = wordcraft_doc::para::Anchor::Margin;
f.v_rel = wordcraft_doc::para::Anchor::Margin;
(f.h_align, f.v_align) = (None, None);
let (tw, th) = (sect.text_width(), sect.text_height());
let col = if preset.ends_with("Left") { 0.0 } else if preset.ends_with("Right") { tw - size.0 } else { (tw - size.0) / 2.0 };
let row = if preset.starts_with("top") { 0.0 } else if preset.starts_with("bottom") { th - size.1 } else { (th - size.1) / 2.0 };
f.x = x.unwrap_or(col);
f.y = y.unwrap_or(row);
})
})
.params(r#"{"preset"?: "topLeft|topCenter|topRight|middleLeft|middleCenter|middleRight|bottomLeft|bottomCenter|bottomRight", "x"?: pt, "y"?: pt}"#)
.when(has_movable),
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_movable),
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_movable),
CommandSpec::new("arrange.align", "Align", "Layout › Arrange", |s, v| {
let h = p::str(v, "value").unwrap_or("center").to_string();
let tw = super::page::sect(s).text_width();
let w = selected(s).map(|(_, o)| obj_size(&o).0).unwrap_or(72.0);
with_float(s, |f| {
if f.wrap == Wrap::Inline {
f.wrap = Wrap::Square;
}
f.h_rel = wordcraft_doc::para::Anchor::Margin;
f.h_align = None;
f.x = match h.as_str() {
"left" => 0.0,
"right" => tw - w,
_ => (tw - w) / 2.0,
};
})
})
.params(r#"{"value": "left|center|right"}"#)
.when(has_movable),
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object floats it)"#)
.when(has_movable),
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
let (dx, dy) = (d("dx"), d("dy"));
unalign(s)?;
with_float(s, |f| {
f.x = (f.x + dx).clamp(-MAX_OFFSET, MAX_OFFSET);
f.y = (f.y + dy).clamp(-MAX_OFFSET, MAX_OFFSET);
})
})
.params(r#"{"dx"?: pt, "dy"?: pt}"#)
.when(has_floating),
CommandSpec::new("arrange.group", "Group", "Layout › Arrange", group)
.params(r#"{} (groups the selected pictures, shapes and text boxes: the selected one plus those added with select.addObject)"#)
.when(can_group),
CommandSpec::new("arrange.ungroup", "Ungroup", "Layout › Arrange", ungroup).when(has_group),
CommandSpec::new("select.addObject", "Add Object to Selection", "Home › Editing › Select", add_object)
.params(r#"{"pos"?: Pos, "index"?: n, "objects"?: [Pos | n]} (n: its Selection Pane index; with no object selected yet, the first one becomes the selection)"#)
.pure(),
CommandSpec::new("select.objects", "Select Objects", "Home › Editing › Select", |s, v| {
let n = p::u64(v, "index").unwrap_or(0) as usize;
let list = all_objects(s);
let (pos, _) = list.into_iter().nth(n).ok_or_else(|| CmdError::Failed("no such object".into()))?;
let end = Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() };
s.sel = Selection { anchor: pos, focus: end };
sel_result(s)
})
.params(r#"{"index"?: n}"#)
.pure(),
CommandSpec::new("shape.fill", "Shape Fill", "Shape Format › Shape Styles", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse);
with_obj(s, |o| {
if let InlineObject::Shape { fill, .. } = o {
*fill = c;
}
})
})
.params(r#"{"color": "RRGGBB" | null}"#)
.when(has_shape),
CommandSpec::new("shape.outline", "Shape Outline", "Shape Format › Shape Styles", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse);
let w = p::f32(v, "width");
with_obj(s, |o| {
if let InlineObject::Shape { stroke, stroke_width, .. } = o {
*stroke = c;
if let Some(w) = w {
*stroke_width = w.clamp(0.0, 72.0);
}
}
})
})
.params(r#"{"color": "RRGGBB" | null, "width"?: pt}"#)
.when(has_shape),
CommandSpec::new("shape.effects", "Shape Effects", "Shape Format › Shape Styles", shape_effects)
.params(
r#"{"shadow"?: preset|{"preset"?, "color"?: "RRGGBB", "transparency"?: %, "blur"?: pt, "distance"?: pt, "angle"?: deg}|null, "glow"?: pt|{"color"?: "RRGGBB", "size"?: pt, "transparency"?: %}|null, "softEdge"?: pt|null} (shadow presets: offsetBottomRight, offsetBottom, offsetBottomLeft, offsetRight, offsetCenter, offsetLeft, offsetTopRight, offsetTop, offsetTopLeft; an omitted key is left as it is; applies to every shape in the selection)"#,
)
.when(has_shape),
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
with_obj(s, |o| {
if let InlineObject::Shape { kind: k, .. } = o {
*k = kind;
}
})
})
.params(r#"{"kind": string}"#)
.when(has_shape),
]
}
/// The selection when it is exactly one picture, shape or text box (its U+FFFC), rather than
/// text: what clicking an object selects.
pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
let (a, b) = s.sel.ordered();
if a.story != b.story || a.path != b.path || b.off != a.off + wordcraft_doc::para::OBJ.len_utf8() {
return None;
}
let o = s.doc.para_at(&a)?.object_at(a.off)?;
o.is_drawing().then_some((a, o))
}
/// The first picture/shape in the selection, or just before a collapsed caret.
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
selected_all(s, 1).into_iter().next()
}
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
/// caret.
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
let mut out = Vec::new();
let (a, b) = s.sel.ordered();
let max = if a == b { 1 } else { max };
let story = a.story;
let paths = if a == b { vec![a.path.clone()] } else { s.doc.paths_between(&a, &b) };
for path in paths {
let Some(p) = s.doc.para(story, &path) else { continue };
for off in p.object_offsets() {
let inside = if a == b {
off + wordcraft_doc::para::OBJ.len_utf8() == a.off || off == a.off
} else {
(path != a.path || off >= a.off) && (path != b.path || off < b.off)
};
if !inside {
continue;
}
if let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) {
out.push((Pos { story, path: path.clone(), off }, o.clone()));
if out.len() >= max {
return out;
}
}
}
}
out
}
/// Most shapes one `shape.effects` changes.
const MAX_EFFECT_SHAPES: usize = 10_000;
/// `shape.effects`: set or clear the shadow, glow and soft edges of the selected shapes.
fn shape_effects(s: &mut Session, v: &Value) -> CmdResult {
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
let color = |o: &Value| p::str(o, "color").and_then(Rgb::parse);
// Each change is `None` (leave as it is) or `Some(new value)`.
let shadow: Option<Option<Shadow>> = match v.get("shadow") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Bool(b)) => Some(b.then(Shadow::default)),
Some(Value::String(id)) if id == "none" => Some(None),
Some(Value::String(id)) => Some(Some(Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?)),
Some(o @ Value::Object(_)) => {
let mut sh = match p::str(o, "preset") {
Some(id) => Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?,
None => Shadow::default(),
};
sh.color = color(o).unwrap_or(sh.color);
sh.transparency = p::f32(o, "transparency").unwrap_or(sh.transparency);
sh.blur = p::f32(o, "blur").unwrap_or(sh.blur);
sh.distance = p::f32(o, "distance").unwrap_or(sh.distance);
sh.angle = p::f32(o, "angle").unwrap_or(sh.angle);
Some(Some(sh.sanitized()))
}
Some(_) => return Err(CmdError::Params("`shadow`: a preset name, an object or null".into())),
};
let glow: Option<Option<Glow>> = match v.get("glow") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(id)) if id == "none" => Some(None),
Some(n @ Value::Number(_)) => Some(Some(Glow { size: n.as_f64().unwrap_or(0.0) as f32, ..Glow::default() }.sanitized())),
Some(o @ Value::Object(_)) => {
let d = Glow::default();
Some(Some(
Glow {
color: color(o).unwrap_or(d.color),
size: p::f32(o, "size").unwrap_or(d.size),
transparency: p::f32(o, "transparency").unwrap_or(d.transparency),
}
.sanitized(),
))
}
Some(_) => return Err(CmdError::Params("`glow`: a size in points, an object or null".into())),
};
let soft: Option<Option<f32>> = match v.get("softEdge") {
None => None,
Some(Value::Null) => Some(None),
Some(n @ Value::Number(_)) => Some(n.as_f64().map(|x| x as f32)),
Some(_) => return Err(CmdError::Params("`softEdge`: a radius in points or null".into())),
};
// The shapes in the selection and those added to it (Shift+click).
let mut targets: Vec<Pos> =
selected_all(s, MAX_EFFECT_SHAPES).into_iter().filter(|(_, o)| matches!(o, InlineObject::Shape { .. })).map(|(pos, _)| pos).collect();
for pos in picked_objects(s) {
if !targets.contains(&pos) && matches!(s.doc.para_at(&pos).and_then(|q| q.object_at(pos.off)), Some(InlineObject::Shape { .. })) {
targets.push(pos);
}
}
if targets.is_empty() {
return Err(CmdError::Disabled("no shape selected".into()));
}
let mut last = ShapeEffects::default();
for pos in &targets {
let o = edit_obj(s, pos, |o| {
if let InlineObject::Shape { effects, float, .. } = o {
let mut e = *effects;
if let Some(x) = shadow {
e.shadow = x;
}
if let Some(x) = glow {
e.glow = x;
}
if let Some(x) = soft {
e.soft_edge = x;
}
*effects = e.sanitized();
// Room around the shape for its shadow and glow, as Word's effect extent.
float.effect = effects.extent();
}
})?;
if let InlineObject::Shape { effects, .. } = o {
last = effects;
}
}
Ok(json!({"shapes": targets.len(), "effects": serde_json::to_value(last).unwrap_or(Value::Null)}))
}
/// Smallest width/height an object can be resized to, points (a text box keeps room for a line).
pub fn min_size(o: &InlineObject) -> f32 {
if matches!(o, InlineObject::Shape { story: Some(_), .. }) { 18.0 } else { 4.0 }
}
/// Largest object offset or move, points.
const MAX_OFFSET: f32 = 4000.0;
/// Resize and/or move the selected object (dragging its frame or handles).
fn bounds(s: &mut Session, v: &Value) -> CmdResult {
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
(Some(x), Some(y)) => Some((x, y)),
(None, None) => None,
_ => return Err(CmdError::Params("`x` and `y` go together".into())),
};
let (w0, h0) = obj_size(&obj);
let min = min_size(&obj);
let w = p::f32(v, "width").unwrap_or(w0).clamp(min, MAX_OFFSET);
let h = p::f32(v, "height").unwrap_or(h0).clamp(min, MAX_OFFSET);
if (w, h) != (w0, h0) {
with_obj(s, |o| o.set_size(w, h))?;
}
let pos = match to {
Some((x, y)) => move_object(s, pos, p::u64(v, "page"), x, y, (w, h))?,
None => pos,
};
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() } };
let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned();
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(&pos)}))
}
/// Put the object at `pos` with its top-left at (x, y) on `page` (default: where it is), as a
/// drag does in Word: it floats (an inline object gets Square wrapping) and anchors to the
/// paragraph under its top edge, positioned relative to that paragraph's column and top. So the
/// text it lands on flows around it, and it moves with that text. On a page where no paragraph
/// starts, it anchors to the nearest line and is positioned on the page. Returns the object's
/// position afterwards.
fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w, h): (f32, f32)) -> Result<Pos, CmdError> {
use wordcraft_doc::para::Anchor;
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
return Err(CmdError::Disabled("only objects in the body text can be moved".into()));
}
let layout = s.layout();
let cur = layout.object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
let page = page.map_or(cur.page, |n| usize::try_from(n).unwrap_or(usize::MAX));
let pg = layout.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {page}")))?;
// Keep a corner of it on the page.
let x = x.max(12.0 - w).min(pg.w - 12.0);
let y = y.max(12.0 - h).min(pg.h.min(MAX_OFFSET) - 12.0);
let (anchor, on_page) = match anchor_paragraph(&layout, page, y) {
Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false),
None => (nearest_line(&layout, page, y).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
};
// Move its character to the anchor (unless it's already in that paragraph).
let at = if anchor.path == pos.path && !on_page {
pos
} else {
let obj = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
let len = wordcraft_doc::para::OBJ.len_utf8();
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
let mut at = anchor;
if at.path == pos.path && at.off > pos.off {
at.off = at.off.saturating_sub(len);
}
let at = s.doc.clamp(&at);
s.doc.insert_object(&at, obj, &wordcraft_doc::CharProps::default())?;
at
};
let (h_rel, v_rel) = if on_page { (Anchor::Page, Anchor::Page) } else { (Anchor::Column, Anchor::Paragraph) };
edit_float(s, &at, |f| {
if f.wrap == Wrap::Inline {
f.wrap = Wrap::Square;
}
f.h_rel = h_rel;
f.v_rel = v_rel;
f.h_align = None;
f.v_align = None;
f.x = 0.0;
f.y = 0.0;
})?;
// Offsets from where the anchor puts it (its paragraph may have moved with the edit).
let origin = if on_page {
wordcraft_geom::Point::new(0.0, 0.0)
} else {
s.touch(); // lay out the edit so far
let layout = s.layout();
layout.object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin
};
edit_float(s, &at, |f| {
f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET);
f.y = (y - origin.y).clamp(-MAX_OFFSET, MAX_OFFSET);
})?;
Ok(at)
}
/// Turn the selected floating object's alignment (left, centred, …) into an offset from its
/// column / paragraph that keeps it where it is, so it can be moved by an offset.
fn unalign(s: &mut Session) -> Result<(), CmdError> {
use wordcraft_doc::para::Anchor;
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
let Some((_, _, float)) = obj.frame() else { return Ok(()) };
if float.h_align.is_none() && float.v_align.is_none() {
return Ok(());
}
let hit = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
let (ox, oy) = (hit.rect.x - hit.origin.x, hit.rect.y - hit.origin.y);
edit_float(s, &pos, |f| {
if f.h_align.is_some() {
f.h_align = None;
f.h_rel = Anchor::Column;
f.x = ox.clamp(-MAX_OFFSET, MAX_OFFSET);
}
if f.v_align.is_some() {
f.v_align = None;
f.v_rel = Anchor::Paragraph;
f.y = oy.clamp(-MAX_OFFSET, MAX_OFFSET);
}
})?;
Ok(())
}
/// The top-level body paragraph under `y` on `page`: the last one starting at or above it (else
/// the first starting on the page).
fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option<wordcraft_doc::Path> {
let starts: Vec<(&wordcraft_doc::Path, f32)> = layout
.pages
.get(page)?
.items
.iter()
.filter_map(|it| match it {
wordcraft_layout::Placed::Lines { story: StoryRef::Body, path, l0: 0, y: top, .. } if path.depth() == 0 => Some((path, *top)),
_ => None,
})
.collect();
starts.iter().rev().find(|(_, top)| *top <= y + 0.5).or(starts.first()).map(|(p, _)| (*p).clone())
}
/// The start of the top-level body line on `page` nearest to `y`.
fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option<Pos> {
let lines = wordcraft_layout::hit::page_lines(layout.pages.get(page)?, StoryRef::Body);
let best = lines.iter().filter(|l| l.path.depth() == 0).min_by(|a, b| {
let d = |l: &wordcraft_layout::hit::LineHit| if y < l.top { l.top - y } else { (y - l.bottom).max(0.0) };
d(a).total_cmp(&d(b))
})?;
let off = best.para.lines.get(best.li)?.start;
Some(Pos { story: StoryRef::Body, path: best.path.clone(), off })
}
fn obj_size(o: &InlineObject) -> (f32, f32) {
o.frame().map_or((0.0, 0.0), |(w, h, _)| (w, h))
}
fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdResult {
let (pos, _) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
let o = edit_obj(s, &pos, f)?;
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
}
/// Change the object at `pos`; returns it afterwards.
fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> 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()))?;
f(o);
let o = o.clone();
para.touch();
Ok(o)
}
fn edit_float(s: &mut Session, pos: &Pos, f: impl Fn(&mut Float)) -> Result<InlineObject, CmdError> {
edit_obj(s, pos, |o| {
if let Some(float) = o.float_mut() {
f(float);
}
})
}
fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
with_obj(s, |o| {
if let Some(float) = o.float_mut() {
let was_inline = float.wrap == Wrap::Inline;
f(float);
// Word's distance from text for a newly wrapped object: 0.125" at the sides.
if was_inline && float.wrap != Wrap::Inline && float.dist == 0.0 {
float.dist = 9.0;
}
}
})
}
fn size(s: &mut Session, v: &Value) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no object".into()))?;
let (w0, h0) = obj_size(&o);
let lock = p::bool(v, "lockAspect").unwrap_or(true);
let (mut w, mut h) = (w0, h0);
if let Some(sc) = p::f32(v, "scale") {
w *= sc / 100.0;
h *= sc / 100.0;
}
match (p::f32(v, "width"), p::f32(v, "height")) {
(Some(nw), Some(nh)) => {
w = nw;
h = nh;
}
(Some(nw), None) => {
if lock && w0 > 0.0 {
h = h0 * nw / w0;
}
w = nw;
}
(None, Some(nh)) => {
if lock && h0 > 0.0 {
w = w0 * nh / h0;
}
h = nh;
}
_ => {}
}
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
with_obj(s, |o| match o {
InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Graphic { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } => {
*ow = w;
*oh = h;
}
_ => {}
})
}
/// Apply an image operation to the selected picture's bitmap (stored as a new PNG).
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let bytes = s.doc.media.get(&media).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
let img = image::load_from_memory(&bytes).map_err(|e| CmdError::Failed(format!("can't decode the picture: {e}")))?;
if (img.width() as u64) * (img.height() as u64) > 80_000_000 {
return Err(CmdError::Failed("picture is too large to edit".into()));
}
let out = f(img);
let mut png = Vec::new();
out.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| CmdError::Failed(e.to_string()))?;
let key = s.doc.add_media(png, "png");
let orig = s.originals.get(&media).cloned().unwrap_or(media.clone());
s.originals.entry(key.clone()).or_insert(orig);
with_obj(s, |o| {
if let InlineObject::Image { media, .. } = o {
*media = key.clone();
}
})
}
fn reset(s: &mut Session, _: &Value) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let orig = s.originals.get(&media).cloned().unwrap_or(media);
with_obj(s, |o| {
if let InlineObject::Image { media, crop, .. } = o {
*media = orig.clone();
*crop = [0.0; 4];
}
})
}
fn recolor(img: image::DynamicImage, mode: &str, sat: f32) -> image::DynamicImage {
let mut rgba = img.to_rgba8();
for px in rgba.pixels_mut() {
let [r, g, b, a] = px.0;
let (rf, gf, bf) = (r as f32, g as f32, b as f32);
let l = 0.299 * rf + 0.587 * gf + 0.114 * bf;
let out = match mode {
"grayscale" => [l, l, l],
"sepia" => [
(0.393 * rf + 0.769 * gf + 0.189 * bf).min(255.0),
(0.349 * rf + 0.686 * gf + 0.168 * bf).min(255.0),
(0.272 * rf + 0.534 * gf + 0.131 * bf).min(255.0),
],
"washout" => [rf * 0.35 + 255.0 * 0.65, gf * 0.35 + 255.0 * 0.65, bf * 0.35 + 255.0 * 0.65],
"blackAndWhite" => {
let v = if l > 127.0 { 255.0 } else { 0.0 };
[v, v, v]
}
"tint" => [l * 0.6 + 0.4 * 0x3B as f32, l * 0.6 + 0.4 * 0x5B as f32, l * 0.6 + 0.4 * 0xDB as f32],
_ => {
let k = sat / 100.0;
[l + (rf - l) * k, l + (gf - l) * k, l + (bf - l) * k]
}
};
px.0 = [out[0].clamp(0.0, 255.0) as u8, out[1].clamp(0.0, 255.0) as u8, out[2].clamp(0.0, 255.0) as u8, a];
}
image::DynamicImage::ImageRgba8(rgba)
}
/// Flood the border-connected region close to the corner colour with transparency.
fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage {
let mut rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
if w == 0 || h == 0 {
return image::DynamicImage::ImageRgba8(rgba);
}
let bg = rgba.get_pixel(0, 0).0;
let close = |p: [u8; 4]| (0..3).map(|i| (p[i] as f32 - bg[i] as f32).abs()).fold(0.0f32, f32::max) <= tol;
let mut seen = vec![false; (w as usize) * (h as usize)];
let mut stack: Vec<(u32, u32)> = Vec::new();
for x in 0..w {
stack.push((x, 0));
stack.push((x, h - 1));
}
for y in 0..h {
stack.push((0, y));
stack.push((w - 1, y));
}
while let Some((x, y)) = stack.pop() {
let i = (y as usize) * (w as usize) + x as usize;
if seen.get(i).copied().unwrap_or(true) {
continue;
}
if let Some(sv) = seen.get_mut(i) {
*sv = true;
}
let p = rgba.get_pixel(x, y).0;
if !close(p) {
continue;
}
rgba.put_pixel(x, y, image::Rgba([p[0], p[1], p[2], 0]));
if x > 0 {
stack.push((x - 1, y));
}
if x + 1 < w {
stack.push((x + 1, y));
}
if y > 0 {
stack.push((x, y - 1));
}
if y + 1 < h {
stack.push((x, y + 1));
}
}
image::DynamicImage::ImageRgba8(rgba)
}
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> image::DynamicImage {
let (w, h) = (img.width(), img.height());
let mut out = image::RgbaImage::from_pixel(w + 2 * border, h + 2 * border, image::Rgba(color));
image::imageops::overlay(&mut out, &img.to_rgba8(), border as i64, border as i64);
image::DynamicImage::ImageRgba8(out)
}
fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
let m = (img.width().max(img.height()) / 40).max(4);
match style {
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255]), m / 2, [40, 40, 40, 255]),
"rounded" | "softEdge" => {
let mut rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
let r = (w.min(h) as f32) * if style == "rounded" { 0.12 } else { 0.0 };
let feather = if style == "softEdge" { (w.min(h) as f32 * 0.08).max(2.0) } else { 1.5 };
for y in 0..h {
for x in 0..w {
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let dx = (r - fx).max(fx - (w as f32 - r)).max(0.0);
let dy = (r - fy).max(fy - (h as f32 - r)).max(0.0);
let edge = (fx.min(w as f32 - fx)).min(fy.min(h as f32 - fy));
let inside = if r > 0.0 && dx > 0.0 && dy > 0.0 { r - (dx * dx + dy * dy).sqrt() } else { edge };
let k = (inside / feather).clamp(0.0, 1.0);
let p = rgba.get_pixel_mut(x, y);
p.0[3] = (p.0[3] as f32 * k) as u8;
}
}
image::DynamicImage::ImageRgba8(rgba)
}
"shadow" => {
let (w, h) = (img.width(), img.height());
let off = m;
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
let shadow = image::DynamicImage::ImageRgba8(shadow);
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
image::imageops::overlay(&mut padded, &shadow.to_rgba8(), (off + off / 2) as i64, (off + off / 2) as i64);
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
image::imageops::overlay(&mut out, &blurred, 0, 0);
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
image::DynamicImage::ImageRgba8(out)
}
_ => frame(img, m, [255, 255, 255, 255]),
}
}
/// The selected pictures, shapes, text boxes and groups: the one the selection holds, then
/// those added to it ([`Session::also_selected`]), each once, in the body.
pub fn picked_objects(s: &Session) -> Vec<Pos> {
let Some((first, _)) = object_selection(s) else { return Vec::new() };
let mut out = vec![first];
for p in &s.also_selected {
if !out.contains(p) && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing) {
out.push(p.clone());
}
}
out
}
/// The object a `select.addObject` item names: a position, or a Selection Pane index.
fn object_ref(s: &Session, v: &Value) -> Result<Pos, CmdError> {
let pos = match v.as_u64() {
Some(n) => all_objects(s).into_iter().nth(usize::try_from(n).unwrap_or(usize::MAX)).map(|(p, _)| p),
None => super::parse_pos(v),
};
pos.filter(|p| p.story == StoryRef::Body && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing))
.ok_or_else(|| CmdError::Params("no picture or shape there".into()))
}
/// Add objects to the selection (Shift+click): the first one is selected if no object is yet;
/// one already selected is taken out again.
fn add_object(s: &mut Session, v: &Value) -> CmdResult {
let mut items: Vec<Value> = match v.get("objects").and_then(Value::as_array) {
Some(a) => a.iter().take(1000).cloned().collect(),
None => Vec::new(),
};
if let Some(p) = v.get("pos") {
items.push(p.clone());
}
if let Some(i) = v.get("index") {
items.push(i.clone());
}
if items.is_empty() {
return Err(CmdError::Params("`pos`, `index` or `objects` required".into()));
}
for it in &items {
let pos = object_ref(s, it)?;
match object_selection(s).map(|(p, _)| p) {
None => {
s.also_selected.clear();
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos } };
}
Some(first) if first == pos => {}
Some(_) => {
if let Some(i) = s.also_selected.iter().position(|p| *p == pos) {
s.also_selected.remove(i);
} else {
s.also_selected.push(pos);
}
}
}
}
Ok(json!({"selected": picked_objects(s).iter().map(super::pos_json).collect::<Vec<_>>()}))
}
/// Group the selected objects (Layout › Arrange › Group): one floating group object, anchored
/// where the first of them was, in the box around them all; they keep their places and sizes.
/// A group among them is merged in (its members join the new group).
fn group(s: &mut Session, _: &Value) -> CmdResult {
use wordcraft_doc::para::GroupChild;
let mut picks = picked_objects(s);
if picks.len() < 2 {
return Err(CmdError::Disabled("select two or more pictures or shapes first".into()));
}
picks.sort();
let layout = s.layout();
let mut found = Vec::new();
for pos in &picks {
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
return Err(CmdError::Disabled("only objects in the body text (not in tables) can be grouped".into()));
}
let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
if matches!(obj, InlineObject::Graphic { .. }) {
return Err(CmdError::Disabled(FROZEN.into()));
}
if !obj.is_floating() {
return Err(CmdError::Disabled("objects in line with text can't be grouped: choose a text wrapping for them first".into()));
}
let hit = layout.object(pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
found.push((obj, hit));
}
let page = found.first().map(|(_, h)| h.page).unwrap_or(0);
if found.iter().any(|(_, h)| h.page != page) {
return Err(CmdError::Disabled("objects on different pages can't be grouped".into()));
}
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for (_, h) in &found {
(x0, y0) = (x0.min(h.rect.x), y0.min(h.rect.y));
(x1, y1) = (x1.max(h.rect.right()), y1.max(h.rect.bottom()));
}
let (w, h) = ((x1 - x0).clamp(1.0, MAX_OFFSET), (y1 - y0).clamp(1.0, MAX_OFFSET));
let mut children = Vec::new();
let member = |obj: &InlineObject, r: [f32; 4]| {
let mut obj = obj.clone();
obj.set_size(r[2], r[3]);
if let Some(f) = obj.float_mut() {
*f = Float::default();
}
GroupChild { x: r[0] - x0, y: r[1] - y0, obj }
};
for (obj, hit) in &found {
let r = hit.rect;
match obj {
InlineObject::Group { .. } => children.extend(obj.group_rects(r.x, r.y, r.w, r.h).into_iter().map(|(cr, c)| member(c, cr))),
_ => children.push(member(obj, [r.x, r.y, r.w, r.h])),
}
}
let float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| *f).unwrap_or_default();
let grouped = InlineObject::Group { w, h, float, ch_w: w, ch_h: h, children };
// Take the members out, last first so the earlier positions hold, and put the group where
// the first one was.
let len = wordcraft_doc::para::OBJ.len_utf8();
for pos in picks.iter().rev() {
s.doc.delete_range(pos, &Pos { off: pos.off + len, ..pos.clone() })?;
}
let first = picks.first().cloned().ok_or_else(|| CmdError::Failed("nothing to group".into()))?;
let at = s.doc.clamp(&first);
s.doc.insert_object(&at, grouped, &wordcraft_doc::CharProps::default())?;
s.touch();
let pos = move_object(s, at, Some(page as u64), x0, y0, (w, h))?;
s.also_selected.clear();
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + len, ..pos.clone() } };
let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned();
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(&pos)}))
}
/// Split the selected group back into its pictures and shapes (Layout › Arrange › Ungroup), in
/// the places and sizes the group shows them, anchored where the group was. They stay
/// selected, so Group puts them back together.
fn ungroup(s: &mut Session, _: &Value) -> CmdResult {
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("select a group first".into()))?;
let InlineObject::Group { w, h, float, .. } = &obj else { return Err(CmdError::Disabled("select a group first".into())) };
// Its offsets, explicit: the members are placed from the same anchor.
let pos = if float.wrap == Wrap::Inline {
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
return Err(CmdError::Disabled("only groups in the body text can be ungrouped in line with text".into()));
}
let hit = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
move_object(s, pos, Some(hit.page as u64), hit.rect.x, hit.rect.y, (*w, *h))?
} else {
unalign(s)?;
pos
};
let obj = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
let Some((w, h, float)) = obj.frame() else { return Err(CmdError::Failed("object vanished".into())) };
let float = *float;
let members: Vec<InlineObject> = obj
.group_rects(0.0, 0.0, w, h)
.into_iter()
.map(|([x, y, cw, ch], c)| {
let mut c = c.clone();
c.set_size(cw.max(min_size(&c)), ch.max(min_size(&c)));
if let Some(f) = c.float_mut() {
*f = Float { x: (float.x + x).clamp(-MAX_OFFSET, MAX_OFFSET), y: (float.y + y).clamp(-MAX_OFFSET, MAX_OFFSET), ..float };
}
c
})
.collect();
if members.is_empty() {
return Err(CmdError::Failed("the group is empty".into()));
}
let len = wordcraft_doc::para::OBJ.len_utf8();
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
let mut at = s.doc.clamp(&pos);
let mut placed = Vec::new();
for m in members {
placed.push(at.clone());
at = s.doc.insert_object(&at, m, &wordcraft_doc::CharProps::default())?;
}
let first = placed.first().cloned().unwrap_or(pos);
s.sel = Selection { anchor: first.clone(), focus: Pos { off: first.off + len, ..first } };
s.also_selected = placed.into_iter().skip(1).collect();
sel_result(s)
}
fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
let mut v = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
for off in p.object_offsets() {
if let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) {
v.push((Pos { story: StoryRef::Body, path: path.clone(), off }, o.clone()));
}
}
}
v
}
fn objects_list(s: &mut Session, _: &Value) -> CmdResult {
Ok(Value::Array(
all_objects(s)
.into_iter()
.enumerate()
.map(|(i, (pos, o))| {
let (kind, name) = match &o {
InlineObject::Image { alt, .. } => ("picture", if alt.is_empty() { format!("Picture {}", i + 1) } else { alt.clone() }),
InlineObject::Shape { kind, .. } => ("shape", format!("{kind:?} {}", i + 1)),
InlineObject::Graphic { graphic, .. } => ("graphic", format!("{:?} {}", graphic.kind, i + 1)),
InlineObject::Group { .. } => ("group", format!("Group {}", i + 1)),
_ => ("object", format!("Object {}", i + 1)),
};
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o)})
})
.collect(),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_fn(w, h, |x, y| {
if x > 2 && x < w - 3 && y > 2 && y < h - 3 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([255, 255, 255, 255]) }
});
let mut b = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png).unwrap();
b
}
/// The laid-out area of the only picture or shape (not a text box).
fn object_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
s.layout().find_object(0, |o| o.text_box.is_none()).unwrap()
}
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
/// select it, then resize and move it with `arrange.bounds`.
fn click_resize_and_drag(insert: &str, params: Value) {
use wordcraft_doc::para::Wrap;
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "Some text "})).unwrap();
s.run(insert, &params).unwrap();
assert!(selected(&s).is_some(), "{insert}: a new object is selected");
s.run("select.collapse", &json!({"end": true})).unwrap();
s.run("text.insert", &json!({"text": " more text"})).unwrap();
assert!(selected(&s).is_none(), "{insert}: typing moved off the object");
// Click: the hit test finds it, and selecting its character selects the object.
let o = object_area(&mut s);
let (cx, cy) = (o.rect.x + o.rect.w / 2.0, o.rect.y + o.rect.h / 2.0);
let hit = s.layout().object_at(o.page, cx, cy, 4.0).unwrap_or_else(|| panic!("{insert}: click misses it"));
assert_eq!(hit.pos(), o.pos());
let end = Pos { off: o.off + wordcraft_doc::para::OBJ.len_utf8(), ..o.pos() };
s.run("select.range", &json!({"anchor": o.pos(), "focus": end})).unwrap();
assert!(selected(&s).is_some(), "{insert}: not selected");
// Resize by a handle: stays inline.
s.run("arrange.bounds", &json!({"width": 160, "height": 90})).unwrap();
let o = object_area(&mut s);
assert_eq!((o.rect.w, o.rect.h, o.wrap), (160.0, 90.0, Wrap::Inline), "{insert}");
// Drag the body: it floats with Square wrap where it was dropped, still selected.
let r = s.run("arrange.bounds", &json!({"page": 0, "x": 250, "y": 300})).unwrap();
assert_eq!(r["object"]["float"]["wrap"], "square", "{insert}");
let o = object_area(&mut s);
assert_eq!((o.page, o.rect.x, o.rect.y, o.rect.w, o.rect.h), (0, 250.0, 300.0, 160.0, 90.0), "{insert}");
assert!(selected(&s).is_some());
// Arrow keys nudge it once it floats.
s.run("arrange.nudge", &json!({"dx": 6, "dy": -2})).unwrap();
let n = object_area(&mut s).rect;
assert!((n.x - 256.0).abs() < 0.01 && (n.y - 298.0).abs() < 0.01, "{insert}: {n:?}");
// The text around it is untouched, and each change is one undo step.
assert_eq!(s.doc.plain_text(StoryRef::Body).replace(wordcraft_doc::para::OBJ, ""), "Some text more text");
s.run("edit.undo", &json!({})).unwrap();
assert_eq!((object_area(&mut s).rect.x, object_area(&mut s).rect.y), (250.0, 300.0), "{insert}");
}
#[test]
fn shapes_can_be_clicked_resized_and_dragged() {
for kind in ["rectangle", "ellipse", "star", "arrow"] {
click_resize_and_drag("insert.shape", json!({"kind": kind}));
}
}
#[test]
fn pictures_can_be_clicked_resized_and_dragged() {
let data = super::super::insert::base64_encode(&png(20, 10));
click_resize_and_drag("insert.picture", json!({"data": data, "width": 100}));
}
#[test]
fn picture_pipeline() {
let mut s = Session::new(wordcraft_doc::Document::new());
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data})).unwrap();
assert!(selected(&s).is_some());
s.run("picture.size", &json!({"width": 100})).unwrap();
let (_, o) = selected(&s).unwrap();
assert_eq!(obj_size(&o), (100.0, 50.0));
let before = s.doc.media.len();
s.run("picture.color", &json!({"mode": "grayscale"})).unwrap();
s.run("picture.removeBackground", &json!({})).unwrap();
assert!(s.doc.media.len() > before);
s.run("arrange.rotate", &json!({"direction": "right"})).unwrap();
assert_eq!(obj_size(&selected(&s).unwrap().1), (50.0, 100.0));
s.run("picture.reset", &json!({})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
s.run("arrange.position", &json!({"preset": "topRight"})).unwrap();
let l = s.run("arrange.selectionPane", &json!({})).unwrap();
assert_eq!(l.as_array().unwrap().len(), 1);
let _ = s.layout();
s.run("select.collapse", &json!({"end": true})).unwrap();
s.run("text.insert", &json!({"text": "x"})).unwrap();
assert!(s.run("picture.crop", &json!({"left": 0.1})).is_err());
}
/// The page rectangle of the object at `pos`.
fn rect_of(s: &mut Session, pos: &Pos) -> [f32; 4] {
let r = s.layout().object(pos, 0).unwrap().rect;
[r.x, r.y, r.w, r.h]
}
fn near(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.5)
}
/// Two floating shapes, Shift+clicked together and grouped: one object that moves as one with
/// its members in place; ungrouping puts them back where the group showed them, still
/// selected, so Group regroups them.
#[test]
fn group_moves_as_one_and_ungroups() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "one\ntwo\nthree\nfour"})).unwrap();
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 50})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 150})).unwrap();
s.run("select.collapse", &json!({"end": true})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 60, "height": 40})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 300, "y": 200})).unwrap();
// One shape selected: nothing to group yet.
assert!(s.run("arrange.group", &json!({})).is_err());
let r = s.run("select.addObject", &json!({"index": 0})).unwrap();
assert_eq!(r["selected"].as_array().unwrap().len(), 2);
let g = s.run("arrange.group", &json!({})).unwrap();
let pos: Pos = serde_json::from_value(g["pos"].clone()).unwrap();
let list = s.run("arrange.selectionPane", &json!({})).unwrap();
assert_eq!(list.as_array().unwrap().len(), 1, "{list}");
assert_eq!(list[0]["kind"], "group");
assert!(s.also_selected.is_empty());
assert!(near(rect_of(&mut s, &pos), [100.0, 150.0, 260.0, 90.0]), "{:?}", rect_of(&mut s, &pos));
// Moving the group moves its members with it.
s.run("arrange.nudge", &json!({"dx": 20, "dy": 10})).unwrap();
let (pos, obj) = selected(&s).unwrap();
let [x, y, w, h] = rect_of(&mut s, &pos);
let members: Vec<[f32; 4]> = obj.group_rects(x, y, w, h).into_iter().map(|(r, _)| r).collect();
assert!(near(members[0], [120.0, 160.0, 100.0, 50.0]) && near(members[1], [320.0, 210.0, 60.0, 40.0]), "{members:?}");
// Drawn there too: the page shows both shapes inside the group's frame.
let shapes = s.layout().pages[0].items.iter().filter(|i| matches!(i, wordcraft_layout::Placed::Shape { .. })).count();
assert_eq!(shapes, 2);
// Ungroup: two shapes again, where the group showed them, both still selected.
s.run("arrange.ungroup", &json!({})).unwrap();
let list = all_objects(&s);
assert_eq!(list.len(), 2);
let rects: Vec<[f32; 4]> = list.iter().map(|(p, _)| rect_of(&mut s, p)).collect();
assert!(rects.iter().any(|r| near(*r, [120.0, 160.0, 100.0, 50.0])), "{rects:?}");
assert!(rects.iter().any(|r| near(*r, [320.0, 210.0, 60.0, 40.0])), "{rects:?}");
assert_eq!(picked_objects(&s).len(), 2);
s.run("arrange.group", &json!({})).unwrap();
assert_eq!(all_objects(&s).len(), 1);
// Undo goes back step by step.
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(all_objects(&s).len(), 2);
}
#[test]
fn picture_alt_and_crop_round_trip() {
let mut s = Session::new(wordcraft_doc::Document::new());
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data})).unwrap();
s.run("picture.altText", &json!({"text": "A red box"})).unwrap();
s.run("picture.crop", &json!({"left": 0.1, "top": 0.2, "right": 0.05, "bottom": 0.0})).unwrap();
let (_, o) = selected(&s).unwrap();
let InlineObject::Image { alt, crop, .. } = o else { panic!("expected image") };
assert_eq!(alt, "A red box");
assert_eq!(crop, [0.1, 0.2, 0.05, 0.0]);
}
}