mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-06 18:51:26 +00:00
Form controls: - Placement: a control hanging from the baseline (as LibreOffice places those it makes) was saved with no placement. LibreOffice then puts it at the line's top, so every control row shrank. Its raise is now kept (`ContentControl::form_raise`, read as in-line frames are) and written, standing on the baseline when it had none. - Multi-line fields: these were saved without LibreOffice's `MultiLine` property, so they showed one centred line. They now carry it. - Text fields get LibreOffice's flat border. - Also: `pt()` no longer writes "-0pt". Custom shapes: - A shape drawn from its own geometry was saved as WordCraft's path of it, and a preset WordCraft draws as a rectangle (a cloud) was saved as a rectangle. The geometry as read (type, path, equations, handles) is now kept (`ShapeExtra::odf_geometry`) and written back, mirrored as the shape now is. - `shape.change` and the ink-to-shape conversion drop the kept geometry, since the shape is then another kind. `ShapeExtra` holds it as an `Arc<String>` to keep `Placed::Shape` small. - The reader's `end` hands skipped elements to `end_skipped`. Results: - The forms document round-trips pixel-identical (2,949 pixels before), with its text the same. - The shape presets fall from 2,467 to 1,220. The rest is LibreOffice's hairline outline, which WordCraft keeps as 0.5 pt. - Across the corpus the round-trip difference falls from 45k to 41k pixels. 42 of 125 documents round-trip pixel-identical, 92 with their text the same, and none gets worse. gaps.md row 19 and the ROADMAP log are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
364 lines
23 KiB
Rust
364 lines
23 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, crop, 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_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};
|
||
|
||
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, a side past 4000 pt shrinks the other with it)"#,
|
||
)
|
||
.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.
|
||
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 base = s.originals.get(&media).cloned().unwrap_or(media.clone());
|
||
adjust_media(s, &base, 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.
|
||
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 base = s.originals.get(&media).cloned().unwrap_or(media.clone());
|
||
adjust_media(s, &base, 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.)
|
||
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 base = s.originals.get(&media).cloned().unwrap_or(media.clone());
|
||
adjust_media(s, &base, 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_movable),
|
||
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_movable),
|
||
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(has_floating),
|
||
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_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 = 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_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, "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_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.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"),
|
||
}),
|
||
]
|
||
}
|