Files
wordcraft/crates/engine/src/cmd/objects/specs.rs
T
0f5a701171 #5469: Size keeps the side it isn't asked to change (#5474)
* #5469: Size keeps the side it isn't asked to change

Typing only the width of a straight line (height 0) gave it a 1 pt
height: picture.size fitted both sides into 1-4000 pt, also the one the
call never set. A side the call leaves as it was now keeps its size,
a line's 0 and a side past 4000 pt from a file too.

Sides set in proportion (the original size, or a scale or one side
typed with the proportions locked while both sides have a length) still
fit the limits together, even when one of them comes out as it was;
any other side the call sets fits on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5469: the table test checks exact sizes

Where both sides follow each other the test only checked that they
stay within 1-4000 pt and keep their proportions. The rule gives one
size there (the one factor nearest 1 that fits both), so each case now
checks that size, worked out by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:57:17 +01:00

368 lines
23 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.
use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
use super::group::{add_object, group, ungroup};
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
use super::select::{
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_placeable, has_shape,
has_shape_or_canvas, has_sizable, obj_size, objects_list, selected, with_float, with_obj,
};
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
use wordcraft_doc::picfx::PicFx;
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, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept (from the original, or with both sides having a length), a side past 4000 pt shrinks the other with it; a side the call leaves as it was keeps its size, a straight line's 0 too)"#,
)
.when(has_sizable),
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop)
.params(
r#"{"left"?, "top"?, "right"?, "bottom"? (fractions of the source 0–0.95; the two sides of an axis keep at least 5% between them; a side left out is uncropped), "width"?: pt, "height"?: pt (the new display size, in the same undo step; a floating picture also moves by what a left/top crop trims, so the visible part stays put)}"#,
)
.when(has_picture),
// Kept for scripts written before every object had alt text: `object.altText` on a picture.
CommandSpec::new("picture.altText", "Alt Text", "Picture Format › Accessibility", |s, v| {
p::req_str(v, "text")?;
alt_text(s, v)
})
.params(r#"{"text": string, "title"?: string, "decorative"?: bool}"#)
.when(has_picture),
CommandSpec::new("object.altText", "Alt Text", "Shape Format › Accessibility", alt_text)
.params(r#"{"text"?: string, "title"?: string, "decorative"?: bool} (the selected picture, shape, text box, group or chart)"#)
.when(has_object),
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);
// Absolute from the original (like transparency): re-setting all three to 0
// restores the original pixels instead of stacking on the last pass.
// The file's own brightness and contrast (`fx`) are replaced, not stacked on.
let base = original_media(s)?;
adjust_media(s, &base, clear_lum, 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 && (-sharpen / 25.0).is_normal() {
// `blur` asserts on a subnormal width.
img = img.blur(-sharpen / 25.0);
}
Ok(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);
// Absolute from the original (like transparency): saturation 100 restores it.
// Color and Corrections are independent, as in Word: only Washout, which Word stores
// as brightness and contrast (`a:lum`), replaces the file's own (`fx`).
let base = original_media(s)?;
let fx: fn(&mut PicFx) = if mode == "washout" { clear_lum } else { |_| {} };
adjust_media(s, &base, fx, move |img| Ok(recolor(img, &mode, sat)))
})
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
.when(has_picture),
CommandSpec::new("picture.iconColor", "Icon Color", "Picture Format › Adjust", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse).ok_or_else(|| CmdError::Params("`color` (RRGGBB) is required".into()))?;
// One colour everywhere, transparency kept: recolours an inserted icon or other
// one-colour line art without redrawing it.
adjust(s, move |img| {
let mut rgba = img.to_rgba8();
for px in rgba.pixels_mut() {
px.0 = [c.0, c.1, c.2, px.0[3]];
}
Ok(image::DynamicImage::ImageRgba8(rgba))
})
})
.params(r#"{"color": "RRGGBB"}"#)
.when(has_picture),
CommandSpec::new("picture.effects", "Artistic Effects", "Picture Format › Adjust", |s, v| {
// One effect at a time, as in Word: a second one replaces the first (#2052).
let e = p::str(v, "effect").unwrap_or("blur").to_string();
relook(s, move |l| l.effect = (e != "none").then_some(e))
})
.params(r#"{"effect": "blur|sharpen|invert|posterize|pixelate|none"} (replaces the picture's effect)"#)
.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);
// Absolute, not compounding: 0% is always the original pixels fully opaque.
// The base is the pre-edit original (kept for Reset Picture), so dragging a
// slider back and forth never fades what a previous pass already faded.
// (Combining with other pixel edits then re-setting transparency drops those
// edits; a full live-effect stack is future work.)
// The file's own transparency (`fx`, `a:alphaModFix`) is replaced too: it's baked
// into the pixels from here on.
let base = original_media(s)?;
adjust_media(s, &base, |fx: &mut PicFx| fx.transparency = 0, 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;
}
Ok(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| Ok(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| Ok(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") {
crate::cmd::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64".into()))?
} else if let Some(path) = p::str(v, "path") {
crate::io::read_file(path).map_err(CmdError::Failed)?
} else {
return Err(CmdError::Params("`path` or `data` required".into()));
};
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
_ => CmdError::Failed("not a supported image".into()),
})?;
let key = s.doc.add_media(bytes.clone(), crate::cmd::insert::image_ext(&bytes));
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| {
// A style replaces the picture's style and border, as Word's gallery does (#2052).
let style = p::str(v, "style").unwrap_or("simpleFrame").to_string();
relook(s, move |l| {
l.style = Some(style);
l.border = None;
})
})
.params(r#"{"style": "simpleFrame|thickFrame|rounded|softEdge|shadow"} (replaces the picture's style and border)"#)
.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;
// Replaces the picture's border, keeping its style (#2052).
relook(s, move |l| l.border = Some((c, w)))
})
.params(r#"{"color"?: "RRGGBB", "width"?: px} (replaces the picture's border)"#)
.when(has_picture),
CommandSpec::new("arrange.rotate", "Rotate", "Layout › Arrange", |s, v| {
// Turns and flips are the object's angle and mirroring (DrawingML `a:xfrm`): the
// picture's pixels are never rewritten. A flip mirrors what is on the page, so a
// rotated object's angle reverses too.
let by = match p::str(v, "direction").unwrap_or("right") {
"left" => Spin::new(270.0, false, false),
"flipHorizontal" | "flipH" => Spin::new(0.0, true, false),
"flipVertical" | "flipV" => Spin::new(0.0, false, true),
"right" => Spin::new(90.0, false, false),
other => return Err(CmdError::Params(format!("unknown direction `{other}`"))),
};
with_float(s, |f| f.set_spin(f.spin().within(by)))
})
.params(r#"{"direction": "right|left|flipVertical|flipHorizontal"} (turns 90° or mirrors the selected picture, shape or text box; its pixels are kept)"#)
.when(has_movable),
CommandSpec::new("arrange.rotation", "Rotation", "Layout › Arrange", |s, v| {
let deg = p::req_f32(v, "degrees")?;
let (fh, fv) = (p::bool(v, "flipH"), p::bool(v, "flipV"));
with_float(s, |f| {
let cur = f.spin();
f.set_spin(Spin::new(deg, fh.unwrap_or(cur.flip_h), fv.unwrap_or(cur.flip_v)));
})
})
.params(r#"{"degrees": number, "flipH"?: bool, "flipV"?: bool} (the exact angle, clockwise, of the selected picture, shape or text box; normalised to 0–360)"#)
.when(has_movable),
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_placeable),
CommandSpec::new("arrange.wrapText", "Wrap Text Sides", "Layout › Arrange", |s, v| {
let side = p::str(v, "side").unwrap_or("bothSides");
let side = WrapText::from_ooxml(side).ok_or_else(|| CmdError::Params(format!("unknown side {side:?}")))?;
with_float(s, |f| f.wrap_text = side)
})
.params(r#"{"side": "bothSides|left|right|largest"} (the sides of the selected Square, Tight or Through object text flows on; largest: the wider side, line by line)"#)
.when(has_placeable),
CommandSpec::new("arrange.moveWithText", "Move with Text", "Layout › Arrange › Wrap Text", |s, v| move_with_text(s, p::bool(v, "value").unwrap_or(true)))
.params(r#"{"value": bool} (true: Move with Text, the floating object's vertical position follows its paragraph; false: Fix Position on Page. It stays where it is)"#)
.when(|s| has_placeable(s).or_else(|| has_floating(s))),
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 = crate::cmd::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_placeable),
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_placeable),
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_placeable),
CommandSpec::new("arrange.align", "Align", "Layout › Arrange", |s, v| {
let h = p::str(v, "value").unwrap_or("center").to_string();
let tw = crate::cmd::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_placeable),
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, "to"?: Pos} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object by x/y floats it. to: an In Line with Text object moves to that text position and stays inline, as a drag does)"#)
.when(has_movable),
CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to)
.params(r#"{"x": pt, "y": pt, "page"?: n} | {"to": Pos} (Ctrl+drag: a copy of the selected object lands with its top-left there, or an In Line with Text one at the text position `to`, and is selected; the original and the clipboard stay as they were)"#)
.when(has_placeable),
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"));
if let Some(r) = crate::cmd::canvas::member_nudge(s, dx, dy) {
return r;
}
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.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").map(|w| w.clamp(0.0, 72.0));
with_obj(s, |o| {
if let InlineObject::Shape { stroke, stroke_width, extra, .. } = o {
*stroke = c;
// A colour chosen here replaces an outline gradient from a file.
extra.outline_fill = None;
if let Some(w) = w {
*stroke_width = w;
}
} else if let Some(style) = crate::cmd::canvas::style_mut(o) {
style.stroke = c;
if let Some(w) = w {
style.stroke_width = w;
}
}
})
})
.params(r#"{"color": "RRGGBB" | null, "width"?: pt} (a selected drawing canvas: its border)"#)
.when(has_shape_or_canvas),
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, extra, .. } = o {
*k = kind;
// A geometry kept from a file is no longer this shape's.
extra.odf_geometry = None;
}
})
})
.params(r#"{"kind": string}"#)
.when(has_shape),
CommandSpec::new("shape.textDirection", "Text Direction", "Shape Format › Text", |s, v| {
let dir = p::str(v, "dir").unwrap_or("horz");
let vert = match dir {
"horz" | "horizontal" => TextVert::Horz,
"vert" | "rotate90" => TextVert::Vert,
"vert270" | "rotate270" => TextVert::Vert270,
"wordArtVert" | "stacked" => TextVert::Stacked,
other => return Err(CmdError::Params(format!("unknown text direction `{other}`"))),
};
set_text_body(s, |b| b.vert = vert)
})
.params(r#"{"dir": "horz|vert|vert270|wordArtVert"} (horizontal, rotate all text 90°, 270°, stacked; the selected text box or the one the caret is in)"#)
.when(has_text_box),
CommandSpec::new("shape.alignText", "Align Text", "Shape Format › Text", |s, v| {
let anchor = match p::str(v, "anchor").unwrap_or("t") {
"t" | "top" => VAlign::Top,
"ctr" | "middle" | "center" => VAlign::Center,
"b" | "bottom" => VAlign::Bottom,
other => return Err(CmdError::Params(format!("unknown text alignment `{other}`"))),
};
set_text_body(s, |b| b.anchor = anchor)
})
.params(r#"{"anchor": "t|ctr|b"} (top, middle, bottom of the box: for turned text, from the side its lines start on)"#)
.when(has_text_box),
CommandSpec::new("shape.link", "Create Link", "Shape Format › Text", link_text_box)
.params(r#"{"target": Pos | n} (the empty text box the selected box's text continues in: its position, or its Selection Pane index)"#)
.when(can_link),
CommandSpec::new("shape.breakLink", "Break Link", "Shape Format › Text", |s, _| {
let (pos, id) = text_box_target(s).ok_or_else(|| CmdError::Disabled(NO_TEXT_BOX.into()))?;
let part = s.doc.parts.get_mut(&id).ok_or_else(|| CmdError::Failed("text box vanished".into()))?;
if part.body.next.take().is_none() {
return Err(CmdError::Disabled("this text box isn't linked to another".into()));
}
edit_obj(s, &pos, |_| {})?;
Ok(json!({"textBox": id}))
})
.params(r#"{} (the text stops at the end of the selected box; the boxes after it go back to their own text)"#)
.when(|s| match text_box_target(s) {
Some((_, id)) if s.doc.parts.get(&id).is_some_and(|p| p.body.next.is_some()) => None,
_ => Some("select a text box linked to another first"),
}),
]
}