mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 20:00:11 -06:00
* picture.change: keep the width and take the new picture's aspect ratio Change Picture swapped only the media key, so a replacement of another shape was stretched into the old frame (a revised figure came out ~2% taller in a real manuscript). Keep the display width, set the height from the new picture's pixel size, and drop the old crop, which described the old picture. Regression test included. * docs: progress log and parity entries for picture-change-aspect --------- Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
380 lines
24 KiB
Rust
380 lines
24 KiB
Rust
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()));
|
||
};
|
||
let (px_w, px_h) = wordcraft_geom::picture::probe(&bytes)
|
||
.map(|p| (p.width, p.height))
|
||
.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, w, h, crop, .. } = o {
|
||
*media = key.clone();
|
||
// The replacement keeps the picture's width and takes its own aspect ratio, so a
|
||
// picture of another shape isn't stretched into the old frame. The old crop
|
||
// described the old picture, so it is dropped.
|
||
if px_w > 0 && px_h > 0 && w.is_finite() && *w > 0.0 {
|
||
let nh = *w * px_h as f32 / px_w as f32;
|
||
if nh.is_finite() && nh > 0.0 {
|
||
*h = nh;
|
||
}
|
||
}
|
||
*crop = [0.0; 4];
|
||
}
|
||
})
|
||
})
|
||
.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"),
|
||
}),
|
||
]
|
||
}
|