mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 21:07:24 -06:00
Fixes #3679 Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2897 lines
153 KiB
Rust
2897 lines
153 KiB
Rust
//! 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::graphic::GraphicKind;
|
||
use wordcraft_doc::para::{Float, InlineObject, Wrap, WrapText};
|
||
use wordcraft_doc::props::{Rgb, TextVert, VAlign};
|
||
use wordcraft_doc::{Pos, StoryRef, TextBody};
|
||
use wordcraft_geom::Spin;
|
||
|
||
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"),
|
||
}
|
||
}
|
||
/// Stories whose objects can be moved, resized and arranged: the body and headers/footers.
|
||
/// Objects in table cells (deeper paths), notes and text boxes stay where their text is.
|
||
fn movable_story(s: &Session, story: StoryRef) -> bool {
|
||
match story {
|
||
StoryRef::Body => true,
|
||
StoryRef::Part(id) => {
|
||
s.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer))
|
||
}
|
||
}
|
||
}
|
||
/// 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_movable` for Size: charts and SmartArt WordCraft can edit are laid out again at their
|
||
/// new size.
|
||
fn has_sizable(s: &Session) -> Option<&'static str> {
|
||
match selected(s) {
|
||
Some((_, InlineObject::Graphic { graphic, .. })) if graphic.chart.is_some() || graphic.smart_art.is_some() => None,
|
||
_ => has_movable(s),
|
||
}
|
||
}
|
||
/// `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),
|
||
}
|
||
}
|
||
/// Word's own ink in a group is kept in the form a group member takes; it can't stand on its own
|
||
/// yet, so such a group stays whole.
|
||
const INK_GROUP: &str = "a group holding Word ink can't be ungrouped yet";
|
||
/// Does group `o` hold a member that can't stand on its own (Word's ink)?
|
||
fn holds_ink(o: &InlineObject) -> bool {
|
||
matches!(o, InlineObject::Group { children, .. } if children.iter().any(|c| matches!(&c.obj, InlineObject::Graphic { graphic, .. } if graphic.kind == GraphicKind::Ink)))
|
||
}
|
||
fn has_group(s: &Session) -> Option<&'static str> {
|
||
match selected(s) {
|
||
Some((_, o)) if o.is_canvas() => Some("a drawing canvas can't be ungrouped"),
|
||
Some((_, o)) if holds_ink(&o) => Some(INK_GROUP),
|
||
Some((_, InlineObject::Group { .. })) => None,
|
||
_ => Some("select a group first"),
|
||
}
|
||
}
|
||
/// Shape Fill and Shape Outline: a shape, or a Drawing Canvas's background and border.
|
||
pub(crate) fn has_shape_or_canvas(s: &Session) -> Option<&'static str> {
|
||
match selected(s) {
|
||
Some((_, o)) if o.is_canvas() => None,
|
||
_ => has_shape(s),
|
||
}
|
||
}
|
||
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, "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);
|
||
}
|
||
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| 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]];
|
||
}
|
||
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;
|
||
}
|
||
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") {
|
||
crate::io::read_file(path).map_err(CmdError::Failed)?
|
||
} 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.clone(), super::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 = 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, "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) = super::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, .. } = o {
|
||
*k = kind;
|
||
}
|
||
})
|
||
})
|
||
.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"),
|
||
}),
|
||
]
|
||
}
|
||
|
||
const NO_TEXT_BOX: &str = "select a text box first";
|
||
|
||
fn has_text_box(s: &Session) -> Option<&'static str> {
|
||
if text_box_target(s).is_some() { None } else { Some(NO_TEXT_BOX) }
|
||
}
|
||
|
||
fn is_text_box(s: &Session, id: u32) -> bool {
|
||
s.doc.parts.get(&id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::TextBox)
|
||
}
|
||
|
||
/// The text box the Shape Format › Text commands work on: the selected one, or the one whose
|
||
/// text the caret is in. Its U+FFFC's position and its story.
|
||
pub fn text_box_target(s: &Session) -> Option<(Pos, u32)> {
|
||
if let Some((pos, InlineObject::Shape { story: Some(id), .. })) = selected(s)
|
||
&& is_text_box(s, id)
|
||
{
|
||
return Some((pos, id));
|
||
}
|
||
let StoryRef::Part(id) = s.sel.focus.story else { return None };
|
||
if !is_text_box(s, id) {
|
||
return None;
|
||
}
|
||
let after = s.doc.text_box_anchor(id)?;
|
||
let off = after.off.checked_sub(wordcraft_doc::para::OBJ.len_utf8())?;
|
||
Some((Pos { off, ..after }, id))
|
||
}
|
||
|
||
/// Change the target text box's text direction, alignment or link.
|
||
pub(crate) fn set_text_body(s: &mut Session, f: impl Fn(&mut TextBody)) -> CmdResult {
|
||
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()))?;
|
||
f(&mut part.body);
|
||
let body = part.body;
|
||
// Its paragraph is laid out again.
|
||
edit_obj(s, &pos, |_| {})?;
|
||
Ok(json!({"textBox": id, "body": body}))
|
||
}
|
||
|
||
fn can_link(s: &Session) -> Option<&'static str> {
|
||
match text_box_target(s) {
|
||
None => Some(NO_TEXT_BOX),
|
||
Some((_, id)) if s.doc.parts.get(&id).is_some_and(|p| p.body.next.is_some()) => Some("this text box is already linked: break the link first"),
|
||
Some(_) => None,
|
||
}
|
||
}
|
||
|
||
/// `shape.link`: the selected text box's text continues in the target box, which must be an
|
||
/// empty text box no other box links to, and not one of those the text already runs through.
|
||
fn link_text_box(s: &mut Session, v: &Value) -> CmdResult {
|
||
let (pos, from) = text_box_target(s).ok_or_else(|| CmdError::Disabled(NO_TEXT_BOX.into()))?;
|
||
let target = v.get("target").ok_or_else(|| CmdError::Params("`target`: the text box to link to".into()))?;
|
||
let at = match target.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(target),
|
||
}
|
||
.ok_or_else(|| CmdError::Params("no object there".into()))?;
|
||
let to = match s.doc.para_at(&at).and_then(|q| q.object_at(at.off)) {
|
||
Some(InlineObject::Shape { story: Some(id), .. }) if is_text_box(s, *id) => *id,
|
||
_ => return Err(CmdError::Params("the target isn't a text box".into())),
|
||
};
|
||
if s.doc.parts.get(&from).is_some_and(|p| p.body.next.is_some()) {
|
||
return Err(CmdError::Failed("this text box is already linked: break the link first".into()));
|
||
}
|
||
if to == from || s.doc.text_box_chain(from).is_some_and(|c| c.contains(&to)) {
|
||
return Err(CmdError::Failed("a text box can't link to itself or to a box its text already runs through".into()));
|
||
}
|
||
if s.doc.text_box_chains().iter().any(|c| c.iter().skip(1).any(|id| *id == to)) {
|
||
return Err(CmdError::Failed("another text box already links to that one".into()));
|
||
}
|
||
if !s.doc.plain_text(StoryRef::Part(to)).trim().is_empty() {
|
||
return Err(CmdError::Failed("the text box to link to must be empty".into()));
|
||
}
|
||
if let Some(p) = s.doc.parts.get_mut(&from) {
|
||
p.body.next = Some(to);
|
||
}
|
||
edit_obj(s, &pos, |_| {})?;
|
||
edit_obj(s, &at, |_| {})?;
|
||
Ok(json!({"textBox": from, "next": to}))
|
||
}
|
||
|
||
/// 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 in_text = text_target(v, &obj)?;
|
||
if in_text.is_some() && to.is_some() {
|
||
return Err(CmdError::Params("`to` or `x`/`y`, not both".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, in_text) {
|
||
(Some((x, y)), _) => move_object(s, pos, p::u64(v, "page"), x, y, (w, h))?,
|
||
(None, Some(at)) => move_in_text(s, pos, &at)?,
|
||
(None, None) => pos,
|
||
};
|
||
select_object(s, &pos);
|
||
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)}))
|
||
}
|
||
|
||
/// Select the object whose character is at `pos`.
|
||
fn select_object(s: &mut Session, pos: &Pos) {
|
||
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() } };
|
||
}
|
||
|
||
/// The text position in `v`'s `to`, for an In Line with Text `obj` (a floating one is placed by
|
||
/// `x`/`y`); None without one.
|
||
fn text_target(v: &Value, obj: &InlineObject) -> Result<Option<Pos>, CmdError> {
|
||
let Some(to) = v.get("to").filter(|t| !t.is_null()) else { return Ok(None) };
|
||
if obj.is_floating() {
|
||
return Err(CmdError::Params("`to` places an In Line with Text object; move a floating one by `x` and `y`".into()));
|
||
}
|
||
super::parse_pos(to).map(Some).ok_or_else(|| CmdError::Params("bad `to`".into()))
|
||
}
|
||
|
||
/// The text position `to` an In Line with Text object at `pos` can land at: in the object's own
|
||
/// story (a table cell there too), not inside a field's code or such (it's clamped to a
|
||
/// character).
|
||
fn landing(s: &Session, pos: &Pos, to: &Pos) -> Result<Pos, CmdError> {
|
||
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
|
||
return Err(CmdError::Disabled("only objects in the body text, headers and footers can be moved".into()));
|
||
}
|
||
if to.story != pos.story || s.doc.para_at(to).is_none() {
|
||
return Err(CmdError::Params("`to` must be a position in the object's own text".into()));
|
||
}
|
||
Ok(s.doc.clamp(to))
|
||
}
|
||
|
||
/// Move the In Line with Text object at `pos` to the text position `to`, where it stays In Line
|
||
/// with Text, keeping its character formatting: Word's drag of an inline picture, which lands
|
||
/// where its dashed insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
|
||
/// Returns the object's position afterwards.
|
||
fn move_in_text(s: &mut Session, pos: Pos, to: &Pos) -> Result<Pos, CmdError> {
|
||
let to = landing(s, &pos, to)?;
|
||
let len = wordcraft_doc::para::OBJ.len_utf8();
|
||
let same_para = to.path == pos.path;
|
||
if same_para && (to.off == pos.off || to.off == pos.off + len) {
|
||
return Ok(pos);
|
||
}
|
||
let para = s.doc.para_at(&pos).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||
let obj = para.object_at(pos.off).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||
let props = para.props_of_char(pos.off).clone();
|
||
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
|
||
let mut at = to;
|
||
if same_para && at.off > pos.off {
|
||
at.off = at.off.saturating_sub(len);
|
||
}
|
||
let at = s.doc.clamp(&at);
|
||
s.doc.insert_object(&at, obj, &props)?;
|
||
Ok(at)
|
||
}
|
||
|
||
/// Ctrl+drag: copy the selected object (a text box gets its own story) and move the copy to
|
||
/// (x, y), or for an In Line with Text object put it at the text position `to`, leaving the
|
||
/// original in place and the clipboard alone. The copy is selected.
|
||
fn copy_to(s: &mut Session, v: &Value) -> CmdResult {
|
||
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
|
||
let len = wordcraft_doc::para::OBJ.len_utf8();
|
||
let end = Pos { off: pos.off + len, ..pos.clone() };
|
||
if let Some(to) = text_target(v, &obj)? {
|
||
if p::f32(v, "x").is_some() || p::f32(v, "y").is_some() {
|
||
return Err(CmdError::Params("`to` or `x`/`y`, not both".into()));
|
||
}
|
||
let at = landing(s, &pos, &to)?;
|
||
let frag = s.doc.copy_range(&pos, &end);
|
||
s.sel = Selection::caret(at.clone());
|
||
super::paste::insert(s, frag)?;
|
||
// The copy starts where it was pasted.
|
||
let copy = s.doc.clamp(&at);
|
||
select_object(s, ©);
|
||
let o = s.doc.para_at(©).and_then(|p| p.object_at(copy.off)).cloned();
|
||
return Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(©)}));
|
||
}
|
||
if p::f32(v, "x").is_none() || p::f32(v, "y").is_none() {
|
||
return Err(CmdError::Params("`x` and `y` are required".into()));
|
||
}
|
||
let frag = s.doc.copy_range(&pos, &end);
|
||
s.sel = Selection::caret(end.clone());
|
||
super::paste::insert(s, frag)?;
|
||
s.touch(); // lay out the copy before moving it
|
||
// The copy sits right after the original; select it and move it like a drag would.
|
||
s.sel = Selection { anchor: end.clone(), focus: Pos { off: end.off + len, ..end } };
|
||
bounds(s, v)
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// A drop across pages can be thrown back: while the object sits on its old page it wraps text
|
||
/// away from the paragraph it is anchored to, so once it has moved, that paragraph can move back
|
||
/// onto the old page itself — and the object goes with it. Dropping near the top of the next
|
||
/// page does it every time (the anchor is that page's first paragraph, which fits on the old page
|
||
/// again once the object's wrap is gone). So a cross-page drop is anchored again until the object
|
||
/// really stays on the page asked for: the later passes see the pagination as it now is. A
|
||
/// same-page move (a resize, a nudge) places once, and a drop that still won't stay keeps its
|
||
/// last place rather than failing.
|
||
fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w, h): (f32, f32)) -> Result<Pos, CmdError> {
|
||
let cur = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.page;
|
||
let target = page.map_or(cur, |n| usize::try_from(n).unwrap_or(usize::MAX));
|
||
let tries = if target == cur { 1 } else { 3 };
|
||
let mut at = pos;
|
||
for _ in 0..tries {
|
||
at = move_object_once(s, &at, target, x, y, (w, h))?;
|
||
s.touch();
|
||
if s.layout().object(&at, target).is_some_and(|hit| hit.page == target) {
|
||
break;
|
||
}
|
||
}
|
||
Ok(at)
|
||
}
|
||
|
||
/// [`move_object`]'s one placement: move the object's character to the anchor under (x, y) on
|
||
/// `page` and offset it from there.
|
||
fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w, h): (f32, f32)) -> Result<Pos, CmdError> {
|
||
use wordcraft_doc::para::Anchor;
|
||
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
|
||
return Err(CmdError::Disabled("only objects in the body text, headers and footers can be moved".into()));
|
||
}
|
||
let story = pos.story;
|
||
let layout = s.layout();
|
||
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);
|
||
// The paragraph under the middle of its top edge: in the column it mostly lands in.
|
||
let (anchor, on_page) = match anchor_paragraph(&s.doc, &layout, page, (x + w / 2.0, y), story) {
|
||
Some(path) => (Pos { story, path, off: 0 }, false),
|
||
None => (nearest_line(&layout, page, y, story).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.story == pos.story && anchor.path == pos.path && !on_page {
|
||
pos.clone()
|
||
} 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.story == pos.story && 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
|
||
};
|
||
// First on the page where it was dropped, so the text wraps around it as it will in the end.
|
||
edit_float(s, &at, |f| {
|
||
if f.wrap == Wrap::Inline {
|
||
f.wrap = Wrap::Square;
|
||
}
|
||
f.h_rel = Anchor::Page;
|
||
f.v_rel = Anchor::Page;
|
||
f.h_align = None;
|
||
f.v_align = None;
|
||
f.x = x.clamp(-MAX_OFFSET, MAX_OFFSET);
|
||
f.y = y.clamp(-MAX_OFFSET, MAX_OFFSET);
|
||
})?;
|
||
if on_page {
|
||
return Ok(at);
|
||
}
|
||
// Then offsets from its paragraph, as laid out with the object there.
|
||
s.touch();
|
||
let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin;
|
||
edit_float(s, &at, |f| {
|
||
f.h_rel = Anchor::Column;
|
||
f.v_rel = Anchor::Paragraph;
|
||
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(())
|
||
}
|
||
|
||
/// Whether a floating object moves with its text (Word's "Move with Text"): its vertical position
|
||
/// follows its paragraph (as layout places any anchor but the page and its margins), rather than
|
||
/// being fixed on the page ("Fix Position on Page").
|
||
pub fn moves_with_text(f: &Float) -> bool {
|
||
use wordcraft_doc::para::Anchor;
|
||
!matches!(f.v_rel, Anchor::Page | Anchor::Margin | Anchor::TopMargin | Anchor::BottomMargin | Anchor::InsideMargin | Anchor::OutsideMargin)
|
||
}
|
||
|
||
/// Arrange › Wrap Text › Move with Text / Fix Position on Page: anchor the selected floating
|
||
/// object's vertical position to its paragraph or to the page, keeping it where it is now.
|
||
fn move_with_text(s: &mut Session, with_text: bool) -> CmdResult {
|
||
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 Err(CmdError::Disabled("select a floating picture or shape first".into())) };
|
||
if moves_with_text(float) == with_text {
|
||
return Ok(json!({"object": serde_json::to_value(&obj).unwrap_or(Value::Null)}));
|
||
}
|
||
let hit = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
|
||
// Paragraph offsets start at the paragraph's top (the hit's origin), page offsets at the page's.
|
||
let y = if with_text { hit.rect.y - hit.origin.y } else { hit.rect.y };
|
||
let o = edit_float(s, &pos, |f| {
|
||
f.v_rel = if with_text { Anchor::Paragraph } else { Anchor::Page };
|
||
f.v_align = None;
|
||
f.y = y.clamp(-MAX_OFFSET, MAX_OFFSET);
|
||
})?;
|
||
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
|
||
}
|
||
|
||
/// The top-level paragraph of `story` under `(x, y)` on `page`: in the column under `x` (from
|
||
/// the section's column geometry), the last one starting at or above `y` (else the first
|
||
/// starting in that column; else, with no paragraph starting in that column, the last starting
|
||
/// at or above `y` anywhere on the page, else the first). Body paragraphs come from the page's
|
||
/// items; header/footer ones from its header and footer. The column matters (#884): layout
|
||
/// places a floating object when it lays out its anchor, so text laid out before that (an
|
||
/// earlier column) can't wrap around it. (Nor do lines continuing a paragraph from the column
|
||
/// before: layout breaks them once, in the column the paragraph starts in.)
|
||
pub(crate) fn anchor_paragraph(
|
||
doc: &wordcraft_doc::Document,
|
||
layout: &wordcraft_layout::DocLayout,
|
||
page: usize,
|
||
(x, y): (f32, f32),
|
||
story: StoryRef,
|
||
) -> Option<wordcraft_doc::Path> {
|
||
let pg = layout.pages.get(page)?;
|
||
let items: Box<dyn Iterator<Item = &wordcraft_layout::Placed> + '_> = match story {
|
||
StoryRef::Body => Box::new(pg.items.iter()),
|
||
StoryRef::Part(_) => Box::new(pg.header.iter().chain(pg.footer.iter())),
|
||
};
|
||
// Top-level text on the page: (path, left, top, starts the paragraph).
|
||
let pieces: Vec<(&wordcraft_doc::Path, f32, f32, bool)> = items
|
||
.filter_map(|it| match it {
|
||
wordcraft_layout::Placed::Lines { story: s, path, l0, x, y: top, .. } if *s == story && path.depth() == 0 => {
|
||
Some((path, *x, *top, *l0 == 0))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let starts: Vec<(&wordcraft_doc::Path, f32)> = pieces.iter().filter(|p| p.3).map(|p| (p.0, p.2)).collect();
|
||
// The page's columns (left, right), from its section's column geometry (headers and footers
|
||
// span the text area); the column under `x` is the rightmost one starting at or left of it.
|
||
let boxes = match story {
|
||
StoryRef::Body => doc.sections().get(pg.section).map(|(_, sp)| sp.column_boxes()).unwrap_or_default(),
|
||
StoryRef::Part(_) => Vec::new(),
|
||
};
|
||
let mut cols: Vec<(f32, f32)> =
|
||
boxes.iter().map(|(cx, w)| (pg.body.x + cx, pg.body.x + cx + w)).filter(|c| c.0.is_finite() && c.1.is_finite()).collect();
|
||
cols.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
let col_of = |px: f32| cols.iter().rposition(|c| c.0 <= px + 0.5).unwrap_or(0);
|
||
let col = col_of(x);
|
||
let in_col: Vec<(&wordcraft_doc::Path, f32)> = pieces.iter().filter(|p| p.3 && col_of(p.1) == col).map(|p| (p.0, p.2)).collect();
|
||
let pick = |v: &[(&wordcraft_doc::Path, f32)]| v.iter().rev().find(|(_, top)| *top <= y + 0.5).or(v.first()).map(|(p, _)| (*p).clone());
|
||
pick(&in_col).or_else(|| pick(&starts))
|
||
}
|
||
|
||
/// The start of the top-level line of `story` on `page` nearest to `y`.
|
||
pub(crate) fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32, story: StoryRef) -> Option<Pos> {
|
||
let lines = wordcraft_layout::hit::page_lines(layout.pages.get(page)?, story);
|
||
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, path: best.path.clone(), off })
|
||
}
|
||
|
||
fn obj_size(o: &InlineObject) -> (f32, f32) {
|
||
o.frame().map_or((0.0, 0.0), |(w, h, _)| (w, h))
|
||
}
|
||
|
||
/// Alt Text: the selected object's description, title and decorative mark, in one undo step.
|
||
fn alt_text(s: &mut Session, v: &Value) -> CmdResult {
|
||
let (text, title, decorative) = (p::str(v, "text"), p::str(v, "title"), p::bool(v, "decorative"));
|
||
if text.is_none() && title.is_none() && decorative.is_none() {
|
||
return Err(CmdError::Params("`text`, `title` or `decorative` is required".into()));
|
||
}
|
||
with_obj(s, |o| {
|
||
if let Some(t) = text {
|
||
o.set_alt_text(t);
|
||
}
|
||
if let Some(f) = o.float_mut() {
|
||
if let Some(t) = title {
|
||
f.alt.title = wordcraft_doc::para::cap_alt(t);
|
||
}
|
||
if let Some(d) = decorative {
|
||
f.alt.decorative = d;
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
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.
|
||
pub(crate) 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;
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `picture.crop`: the crop, and optionally the new display size, as one edit. With a size, a
|
||
/// floating picture positioned by offsets moves by what the crop on its left/top side on the page
|
||
/// trims (or restores) — for a flipped picture that's the source's right/bottom — so what stays
|
||
/// visible keeps its place on the page: a crop handle drop is one command.
|
||
fn crop(s: &mut Session, v: &Value) -> CmdResult {
|
||
let mut 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.95));
|
||
// The two sides of an axis keep at least 5% of the source between them, as the renderer
|
||
// draws it: the second side stops where the first one already left less than that.
|
||
c[2] = c[2].min(0.95 - c[0]);
|
||
c[3] = c[3].min(0.95 - c[1]);
|
||
let size = |k: &str| p::f32(v, k).map(|x| x.clamp(1.0, 4000.0));
|
||
let (nw, nh) = (size("width"), size("height"));
|
||
with_obj(s, |o| {
|
||
if let InlineObject::Image { crop, w, h, float, .. } = o {
|
||
// The source's full size at the new display size (the visible part is 1-lo-hi of it).
|
||
let full = |len: f32, lo: f32, hi: f32| len / (1.0 - lo - hi).max(0.05);
|
||
// Flipped, the source's right (bottom) edge is the one on the page's left (top).
|
||
let (left, top) = (if float.flip_h { 2 } else { 0 }, if float.flip_v { 3 } else { 1 });
|
||
if let Some(nw) = nw {
|
||
if float.wrap != Wrap::Inline && float.h_align.is_none() {
|
||
float.x += (c[left] - crop[left]) * full(nw, c[0], c[2]);
|
||
}
|
||
*w = nw;
|
||
}
|
||
if let Some(nh) = nh {
|
||
if float.wrap != Wrap::Inline && float.v_align.is_none() {
|
||
float.y += (c[top] - crop[top]) * full(nh, c[1], c[3]);
|
||
}
|
||
*h = nh;
|
||
}
|
||
*crop = c;
|
||
}
|
||
})
|
||
}
|
||
|
||
/// A picture's original size: its image's pixels at 96 ppi (the size Insert › Pictures gives it)
|
||
/// or a metafile's own frame, before any crop.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct OriginalSize {
|
||
/// Points.
|
||
pub w: f32,
|
||
pub h: f32,
|
||
/// A bitmap's pixels (None for a metafile).
|
||
pub pixels: Option<(u32, u32)>,
|
||
}
|
||
|
||
/// The original size of the picture showing `media`, read from the image its Picture Format
|
||
/// adjustments started from (Compress Pictures may have resampled the current one); None when the
|
||
/// image can't be read.
|
||
pub fn original_size(s: &Session, media: &str) -> Option<OriginalSize> {
|
||
let key = s.originals.get(media).map_or(media, String::as_str);
|
||
let bytes = s.doc.media.get(key).or_else(|| s.doc.media.get(media))?;
|
||
if let Some((pw, ph)) = wordcraft_render::image_size(bytes) {
|
||
return Some(OriginalSize { w: pw as f32 * 0.75, h: ph as f32 * 0.75, pixels: Some((pw, ph)) });
|
||
}
|
||
let (w, h, _) = wordcraft_render::metafile_size(bytes)?;
|
||
Some(OriginalSize { w, h, pixels: None })
|
||
}
|
||
|
||
/// What a picture's crop leaves of its original size: the size Reset gives back and what a scale
|
||
/// of the original counts from. None for anything but a picture whose image can be read.
|
||
pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)> {
|
||
let InlineObject::Image { media, crop, .. } = o else { return None };
|
||
let orig = original_size(s, media)?;
|
||
let part = |lo: f32, hi: f32| (1.0 - lo - hi).max(0.05);
|
||
Some((orig.w * part(crop[0], crop[2]), orig.h * part(crop[1], crop[3])))
|
||
}
|
||
|
||
/// Size: `scale` is a percentage of the current size, or with `original` of what the crop leaves
|
||
/// of the original size (`original` alone gives that back: the Size dialog's Reset); then `width`
|
||
/// and `height`, where one alone keeps the proportions unless `lockAspect` is false.
|
||
fn size(s: &mut Session, v: &Value) -> CmdResult {
|
||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no object".into()))?;
|
||
let lock = p::bool(v, "lockAspect").unwrap_or(true);
|
||
let (mut w, mut h) = obj_size(&o);
|
||
let scale = p::f32(v, "scale");
|
||
if p::bool(v, "original").unwrap_or(false) {
|
||
let (ow, oh) =
|
||
cropped_original_size(s, &o).ok_or_else(|| CmdError::Disabled("only a picture whose image can be read has an original size".into()))?;
|
||
let k = scale.unwrap_or(100.0) / 100.0;
|
||
(w, h) = (ow * k, oh * k);
|
||
} else if let Some(sc) = scale {
|
||
w *= sc / 100.0;
|
||
h *= sc / 100.0;
|
||
}
|
||
let (nw, nh) = (p::f32(v, "width"), p::f32(v, "height"));
|
||
match (nw, nh) {
|
||
(Some(nw), Some(nh)) => {
|
||
w = nw;
|
||
h = nh;
|
||
}
|
||
(Some(nw), None) => {
|
||
if lock && w > 0.0 {
|
||
h *= nw / w;
|
||
}
|
||
w = nw;
|
||
}
|
||
(None, Some(nh)) => {
|
||
if lock && h > 0.0 {
|
||
w *= nh / h;
|
||
}
|
||
h = nh;
|
||
}
|
||
_ => {}
|
||
}
|
||
// A size that keeps the proportions shrinks on both sides to fit, rather than flattening.
|
||
let keep = match (nw, nh) {
|
||
(Some(_), Some(_)) => false,
|
||
(None, None) => true,
|
||
_ => lock,
|
||
};
|
||
let (w, h) = fit_size(w, h, keep);
|
||
// Any drawing: a group's members scale with its frame (its child space is kept); a chart or
|
||
// SmartArt graphic lays itself out again at the new size.
|
||
let theme = s.doc.settings.theme_colors.clone();
|
||
with_obj(s, |o| {
|
||
o.set_size(w, h);
|
||
super::chart::redraw(o, &theme);
|
||
super::smart_art::redraw(o, &theme);
|
||
})
|
||
}
|
||
|
||
/// An object size within 1–4000 pt. With `keep`, both sides scale by one factor, the one nearest
|
||
/// 1 that brings them within the limits, so the proportions hold; a shape too long and thin for any
|
||
/// such factor is clamped side by side.
|
||
pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
|
||
let side = |x: f32| if x.is_nan() { 1.0 } else { x.clamp(1.0, 4000.0) };
|
||
if keep && w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 {
|
||
let (lo, hi) = ((1.0 / w).max(1.0 / h), (4000.0 / w).min(4000.0 / h));
|
||
if lo <= hi {
|
||
let k = if lo > 1.0 {
|
||
lo
|
||
} else if hi < 1.0 {
|
||
hi
|
||
} else {
|
||
1.0
|
||
};
|
||
return (side(w * k), side(h * k));
|
||
}
|
||
}
|
||
(side(w), side(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())) };
|
||
adjust_media(s, &media, f)
|
||
}
|
||
|
||
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
|
||
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
|
||
/// Picture keeps working.
|
||
fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
|
||
let bytes = s.doc.media.get(base).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");
|
||
// Chain onto the ultimate original (the base's, or the current picture's when the base
|
||
// is itself derived), so Reset Picture always finds it.
|
||
let cur = selected(s).and_then(|(_, o)| match o {
|
||
InlineObject::Image { media, .. } => Some(media),
|
||
_ => None,
|
||
});
|
||
let orig = s
|
||
.originals
|
||
.get(base)
|
||
.cloned()
|
||
.or_else(|| cur.as_ref().and_then(|m| s.originals.get(m).cloned()))
|
||
.unwrap_or_else(|| cur.clone().unwrap_or_else(|| key.clone()));
|
||
s.originals.entry(key.clone()).or_insert(orig);
|
||
with_obj(s, |o| {
|
||
if let InlineObject::Image { media, .. } = o {
|
||
*media = key.clone();
|
||
}
|
||
})
|
||
}
|
||
|
||
/// What Artistic Effects, Picture Styles and Picture Border drew on a picture's bitmap: the
|
||
/// bitmap they started from and the effect, style and border on it, so the next one draws again
|
||
/// from that bitmap instead of over the last result. Picking a second style replaces the first,
|
||
/// as in Word, instead of framing the frame (#2052). Kept in [`Session::picture_looks`] under the
|
||
/// media key of the drawn bitmap.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct PictureLook {
|
||
/// The media key of the bitmap before any of them.
|
||
pub base: String,
|
||
pub effect: Option<String>,
|
||
pub style: Option<String>,
|
||
pub border: Option<(Rgb, u32)>,
|
||
/// The drawn bitmap: a media key that names other bytes later (undo then a new picture
|
||
/// reusing the key) is not this look's.
|
||
pub drawn: std::sync::Arc<Vec<u8>>,
|
||
}
|
||
|
||
/// Change the selected picture's effect, style or border and draw it again from the bitmap
|
||
/// under them (effect, then style, then border).
|
||
fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) -> 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 cur = s.doc.media.get(&media).cloned();
|
||
let mut look = s
|
||
.picture_looks
|
||
.get(&media)
|
||
.filter(|l| cur.as_ref().is_some_and(|c| std::sync::Arc::ptr_eq(c, &l.drawn)) && s.doc.media.contains_key(&l.base))
|
||
.cloned()
|
||
.unwrap_or_else(|| PictureLook { base: media.clone(), ..Default::default() });
|
||
change(&mut look);
|
||
if look.effect.is_none() && look.style.is_none() && look.border.is_none() {
|
||
// Nothing left on it: the bitmap as it was.
|
||
let base = look.base.clone();
|
||
return with_obj(s, |o| {
|
||
if let InlineObject::Image { media, .. } = o {
|
||
*media = base.clone();
|
||
}
|
||
});
|
||
}
|
||
let l = look.clone();
|
||
let out = adjust_media(s, &look.base, move |img| {
|
||
let mut img = match &l.effect {
|
||
Some(e) => artistic_effect(img, e),
|
||
None => img,
|
||
};
|
||
if let Some(st) = &l.style {
|
||
img = picture_style(img, st);
|
||
}
|
||
if let Some((c, w)) = l.border {
|
||
img = frame(img, w, [c.0, c.1, c.2, 255]);
|
||
}
|
||
img
|
||
})?;
|
||
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
|
||
&& let Some(drawn) = s.doc.media.get(&media).cloned()
|
||
{
|
||
s.picture_looks.insert(media, PictureLook { drawn, ..look });
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
fn artistic_effect(img: image::DynamicImage, effect: &str) -> image::DynamicImage {
|
||
match effect {
|
||
"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,
|
||
}
|
||
}
|
||
|
||
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| movable_story(s, p.story) && 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();
|
||
let story = picks.first().cloned().map(|p| p.story).ok_or_else(|| CmdError::Failed("nothing to group".into()))?;
|
||
if picks.iter().any(|p| p.story != story) {
|
||
return Err(CmdError::Disabled("objects in different stories (body, header, footer) can't be grouped".into()));
|
||
}
|
||
for pos in &picks {
|
||
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
|
||
return Err(CmdError::Disabled("only objects in the body text, headers and footers (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_canvas() {
|
||
return Err(CmdError::Disabled("a drawing canvas can't be grouped".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()));
|
||
}
|
||
// The members as they show on the page: each one's unturned frame and turn (a group among
|
||
// them gives its members, its turn taken into theirs).
|
||
let mut shown: Vec<([f32; 4], Spin, &InlineObject)> = Vec::new();
|
||
for (obj, hit) in &found {
|
||
let r = hit.rect;
|
||
match obj {
|
||
InlineObject::Group { .. } => shown.extend(shown_members(obj, [r.x, r.y, r.w, r.h])),
|
||
_ => shown.push(([r.x, r.y, r.w, r.h], hit.spin, obj)),
|
||
}
|
||
}
|
||
// The group's box takes in what they cover, turned.
|
||
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||
for ([x, y, w, h], spin, _) in &shown {
|
||
let b = spin.bounds(wordcraft_geom::Rect::new(*x, *y, *w, *h));
|
||
(x0, y0) = (x0.min(b.x), y0.min(b.y));
|
||
(x1, y1) = (x1.max(b.right()), y1.max(b.bottom()));
|
||
}
|
||
if shown.is_empty() {
|
||
return Err(CmdError::Failed("nothing to group".into()));
|
||
}
|
||
// `shown` already has the members of any picked group merged in, so this counts the
|
||
// flattened members. Readers stop at the cap and would drop the rest.
|
||
if shown.len() > wordcraft_doc::para::MAX_GROUP_CHILDREN {
|
||
return Err(CmdError::Disabled(format!("too many objects to group (at most {})", wordcraft_doc::para::MAX_GROUP_CHILDREN)));
|
||
}
|
||
let (w, h) = ((x1 - x0).clamp(1.0, MAX_OFFSET), (y1 - y0).clamp(1.0, MAX_OFFSET));
|
||
// Each member keeps its turn: the group itself starts unturned.
|
||
let children: Vec<GroupChild> = shown
|
||
.into_iter()
|
||
.map(|([x, y, cw, ch], spin, obj)| {
|
||
let mut obj = obj.clone();
|
||
obj.set_size(cw, ch);
|
||
if let Some(f) = obj.float_mut() {
|
||
// Each member keeps its own alt text.
|
||
let alt = std::mem::take(&mut f.alt);
|
||
*f = Float { alt, ..Float::default() };
|
||
f.set_spin(spin);
|
||
}
|
||
GroupChild { x: x - x0, y: y - y0, obj }
|
||
})
|
||
.collect();
|
||
// The group takes the first member's placement; its members keep their own alt text.
|
||
let mut float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| Float { alt: Default::default(), ..f.clone() }).unwrap_or_default();
|
||
float.set_spin(Spin::default());
|
||
let grouped = InlineObject::Group { w, h, float, ch_w: w, ch_h: h, children, canvas: None };
|
||
// 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())) };
|
||
if holds_ink(&obj) {
|
||
return Err(CmdError::Disabled(INK_GROUP.into()));
|
||
}
|
||
// Its offsets, explicit: the members are placed from the same anchor.
|
||
let pos = if float.wrap == Wrap::Inline {
|
||
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
|
||
return Err(CmdError::Disabled("only groups in the body text, headers and footers 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.clone();
|
||
// Each member where the group shows it: the group's turn moves its centre about the group's
|
||
// centre and adds to its own turn.
|
||
let members: Vec<InlineObject> = shown_members(&obj, [0.0, 0.0, w, h])
|
||
.into_iter()
|
||
.map(|([x, y, cw, ch], spin, c)| {
|
||
let mut c = c.clone();
|
||
let (mw, mh) = (cw.max(min_size(&c)), ch.max(min_size(&c)));
|
||
c.set_size(mw, mh);
|
||
// Grown to its smallest size about its centre, so it stays centred where it was.
|
||
let (x, y) = (x - (mw - cw) / 2.0, y - (mh - ch) / 2.0);
|
||
if let Some(f) = c.float_mut() {
|
||
// Each member keeps its own alt text; the group's placement is shared out.
|
||
let alt = std::mem::take(&mut f.alt);
|
||
*f = Float { x: (float.x + x).clamp(-MAX_OFFSET, MAX_OFFSET), y: (float.y + y).clamp(-MAX_OFFSET, MAX_OFFSET), alt, ..float.clone() };
|
||
f.set_spin(spin);
|
||
}
|
||
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)
|
||
}
|
||
|
||
/// A group's members as it shows them in its frame `[x, y, w, h]`: each member's unturned frame
|
||
/// and the turn it is drawn with (its own inside the group's). The group's turn, about its
|
||
/// centre, moves each member's centre. Empty for anything but a group.
|
||
fn shown_members(group: &InlineObject, [x, y, w, h]: [f32; 4]) -> Vec<([f32; 4], Spin, &InlineObject)> {
|
||
let outer = group.frame().map(|(_, _, f)| f.spin()).unwrap_or_default();
|
||
let [x, y, w, h] = [x, y, w, h].map(wordcraft_geom::finite);
|
||
let (gx, gy) = (x + w / 2.0, y + h / 2.0);
|
||
group
|
||
.group_rects(x, y, w, h)
|
||
.into_iter()
|
||
.map(|([mx, my, mw, mh], c)| {
|
||
let own = c.frame().map(|(_, _, f)| f.spin()).unwrap_or_default();
|
||
let (cx, cy) = outer.apply(gx, gy, mx + mw / 2.0, my + mh / 2.0);
|
||
([cx - mw / 2.0, cy - mh / 2.0, mw, mh], own.within(outer), c)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub(crate) 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)),
|
||
};
|
||
let hidden = o.frame().is_some_and(|(_, _, f)| f.hidden);
|
||
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o), "hidden": hidden})
|
||
})
|
||
.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, ¶ms).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}");
|
||
}
|
||
|
||
/// Ctrl+drag: a copy lands where dropped and is selected; the original stays, the clipboard
|
||
/// is untouched, and it is one undo step.
|
||
#[test]
|
||
fn copy_to_drops_a_copy_and_leaves_the_original() {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("text.insert", &json!({"text": "Some text "})).unwrap();
|
||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||
s.run("arrange.bounds", &json!({"width": 100, "height": 60, "page": 0, "x": 100, "y": 200})).unwrap();
|
||
s.clipboard_text = "keep".into();
|
||
let count = |s: &Session| s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().objects.len();
|
||
assert_eq!(count(&s), 1);
|
||
s.run("arrange.copyTo", &json!({"page": 0, "x": 300, "y": 200})).unwrap();
|
||
assert_eq!(count(&s), 2);
|
||
assert_eq!(s.clipboard_text, "keep");
|
||
let (_, sel) = selected(&s).unwrap();
|
||
assert!(matches!(sel, InlineObject::Shape { .. }));
|
||
let at = s.layout().find_object(0, |o| (o.rect.x - 300.0).abs() < 0.5).expect("the copy is at the drop point");
|
||
assert_eq!(at.pos(), selected(&s).unwrap().0);
|
||
assert!(s.layout().find_object(0, |o| (o.rect.x - 100.0).abs() < 0.5).is_some(), "the original stays");
|
||
assert!(s.run("arrange.copyTo", &json!({"page": 0})).is_err());
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(count(&s), 1);
|
||
}
|
||
|
||
/// The body's top-level paragraphs, an object's character shown as ▣.
|
||
fn body_text(s: &Session) -> String {
|
||
(0..2)
|
||
.filter_map(|i| s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(i)))
|
||
.map(|p| p.text.replace(wordcraft_doc::para::OBJ, "▣"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
/// Two paragraphs, the second starting with an inline picture (bold, so its formatting can be
|
||
/// followed), selected.
|
||
fn inline_picture_session() -> Session {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("text.insert", &json!({"text": "First paragraph."})).unwrap();
|
||
s.run("text.newParagraph", &json!({})).unwrap();
|
||
s.run("format.bold", &json!({})).unwrap();
|
||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||
s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap();
|
||
s.run("format.bold", &json!({})).unwrap();
|
||
s.run("text.insert", &json!({"text": " second paragraph."})).unwrap();
|
||
let pic = Pos::body(1, 0);
|
||
s.run("select.range", &json!({"anchor": pic, "focus": Pos { off: pic.off + wordcraft_doc::para::OBJ.len_utf8(), ..pic.clone() }})).unwrap();
|
||
assert!(selected(&s).is_some_and(|(_, o)| !o.is_floating()));
|
||
s
|
||
}
|
||
|
||
/// #2234: dragging an In Line with Text picture into the text (`to`) moves it there and it stays
|
||
/// inline, with its formatting, selected, in one undo step; it never floats.
|
||
#[test]
|
||
fn an_inline_picture_dragged_into_the_text_stays_inline() {
|
||
let mut s = inline_picture_session();
|
||
let text = body_text;
|
||
assert_eq!(text(&s), "First paragraph.\n▣ second paragraph.");
|
||
// Into the first paragraph, after "First".
|
||
let r = s.run("arrange.bounds", &json!({"to": Pos::body(0, 5)})).unwrap();
|
||
assert_eq!(text(&s), "First▣ paragraph.\n second paragraph.");
|
||
assert_eq!(r["object"]["float"]["wrap"], "inline", "{r}");
|
||
let (pos, obj) = selected(&s).expect("still selected");
|
||
assert_eq!(pos, Pos::body(0, 5));
|
||
assert!(!obj.is_floating());
|
||
assert!(s.doc.para_at(&pos).unwrap().props_of_char(pos.off).bold == Some(true), "keeps its run's formatting");
|
||
// Later in its own paragraph (after "paragraph", counted with it still there): the offset
|
||
// accounts for its own character leaving.
|
||
s.run("arrange.bounds", &json!({"to": Pos::body(0, 18)})).unwrap();
|
||
assert_eq!(text(&s), "First paragraph▣.\n second paragraph.");
|
||
// Each move is one undo step.
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(text(&s), "First▣ paragraph.\n second paragraph.");
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(text(&s), "First paragraph.\n▣ second paragraph.");
|
||
// Dropped on itself (either side): nothing changes.
|
||
let here = selected(&s).unwrap().0;
|
||
s.run("arrange.bounds", &json!({"to": here})).unwrap();
|
||
s.run("arrange.bounds", &json!({"to": Pos { off: here.off + wordcraft_doc::para::OBJ.len_utf8(), ..here.clone() }})).unwrap();
|
||
assert_eq!(text(&s), "First paragraph.\n▣ second paragraph.");
|
||
assert_eq!(selected(&s).unwrap().0, here);
|
||
}
|
||
|
||
/// `to` is only for In Line with Text objects, in their own text; bad targets change nothing.
|
||
#[test]
|
||
fn moving_into_the_text_refuses_bad_targets() {
|
||
let mut s = inline_picture_session();
|
||
let before = body_text(&s);
|
||
for bad in [
|
||
json!({"to": "junk"}),
|
||
json!({"to": {"story": "body", "path": [9], "off": 0}}),
|
||
json!({"to": Pos::body(0, 0), "x": 10, "y": 10}),
|
||
json!({"to": {"story": {"part": 99}, "path": [0], "off": 0}}),
|
||
] {
|
||
assert!(s.run("arrange.bounds", &bad).is_err(), "{bad}");
|
||
assert!(s.run("arrange.copyTo", &bad).is_err(), "{bad}");
|
||
}
|
||
// A huge offset is clamped to the paragraph's end.
|
||
s.run("arrange.bounds", &json!({"to": {"story": "body", "path": [0], "off": 999_999}})).unwrap();
|
||
assert_eq!(body_text(&s), "First paragraph.▣\n second paragraph.");
|
||
assert_ne!(body_text(&s), before);
|
||
// A floating object is moved by x/y, not into the text.
|
||
s.run("arrange.bounds", &json!({"page": 0, "x": 200, "y": 300})).unwrap();
|
||
assert!(s.run("arrange.bounds", &json!({"to": Pos::body(1, 0)})).is_err());
|
||
}
|
||
|
||
/// Ctrl+drag of an inline picture into the text: a copy lands there, inline and selected; the
|
||
/// original stays.
|
||
#[test]
|
||
fn copying_an_inline_picture_into_the_text() {
|
||
let mut s = inline_picture_session();
|
||
s.clipboard_text = "keep".into();
|
||
s.run("arrange.copyTo", &json!({"to": Pos::body(0, 5)})).unwrap();
|
||
assert_eq!(body_text(&s), "First▣ paragraph.\n▣ second paragraph.");
|
||
let (pos, obj) = selected(&s).expect("the copy is selected");
|
||
assert_eq!(pos, Pos::body(0, 5));
|
||
assert!(!obj.is_floating());
|
||
assert_eq!(s.clipboard_text, "keep");
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(body_text(&s), "First paragraph.\n▣ second paragraph.");
|
||
}
|
||
|
||
#[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}));
|
||
}
|
||
|
||
/// #965: a picture or icon inserted by a command leaves the caret after it, so a script's next
|
||
/// Enter or typing keeps it (and the saved file has it); Picture Format commands still reach it.
|
||
#[test]
|
||
fn typing_after_an_inserted_picture_keeps_it() {
|
||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||
for (id, params) in [("insert.picture", json!({"data": data, "width": 100})), ("insert.icon", json!({"id": "sun"}))] {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||
s.run("text.newParagraph", &json!({})).unwrap();
|
||
s.run(id, ¶ms).unwrap();
|
||
assert!(s.sel.is_collapsed(), "{id}: caret, not a selection");
|
||
assert!(selected(&s).is_some(), "{id}: still the object Picture Format acts on");
|
||
s.run("text.newParagraph", &json!({})).unwrap();
|
||
s.run("text.insert", &json!({"text": "cap"})).unwrap();
|
||
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(1), "{id}");
|
||
let paras: Vec<String> = s
|
||
.doc
|
||
.body
|
||
.iter()
|
||
.filter_map(|b| match &**b {
|
||
wordcraft_doc::Block::Para(p) => Some(p.text.replace(wordcraft_doc::para::OBJ, "#")),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(paras, ["X", "#", "cap"], "{id}");
|
||
let docx = crate::io::save_bytes("p.docx", &s.doc).unwrap();
|
||
assert!(docx.windows(11).any(|w| w == b"word/media/"), "{id}: the saved file keeps the picture");
|
||
}
|
||
}
|
||
|
||
/// #965: two pictures inserted by commands in a row are both kept; the second doesn't
|
||
/// replace the first.
|
||
#[test]
|
||
fn two_inserted_pictures_in_a_row_are_both_kept() {
|
||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(2));
|
||
}
|
||
|
||
/// #2019: Insert › Shapes adds each new shape beside the selected one, as Word draws it, so
|
||
/// shapes inserted in a row are all kept (the second used to replace the first, which stays
|
||
/// selected after its insert). Selected text stays too, and the new shape is selected.
|
||
#[test]
|
||
fn shapes_inserted_in_a_row_are_all_kept_and_keep_selected_text() {
|
||
let objects = |s: &mut Session| s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len);
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
for kind in ["rectangle", "ellipse", "star"] {
|
||
s.run("insert.shape", &json!({"kind": kind})).unwrap();
|
||
}
|
||
assert_eq!(objects(&mut s), Some(3));
|
||
|
||
let mut s = Session::new(wordcraft_doc::Document::from_text("Kiln room"));
|
||
s.run("select.text", &json!({"text": "Kiln"})).unwrap();
|
||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||
let text = s.doc.plain_text(StoryRef::Body);
|
||
assert!(text.starts_with("Kiln") && text.ends_with(" room"), "{text:?}");
|
||
assert_eq!(objects(&mut s), Some(1));
|
||
let (a, b) = s.sel.ordered();
|
||
let obj = wordcraft_doc::para::OBJ.len_utf8();
|
||
assert_eq!((a.off, b.off), (4, 4 + obj), "the new shape, after the text, is selected");
|
||
}
|
||
|
||
/// Insert › Pictures does replace a selected picture, as Word does (checked in Word for
|
||
/// Windows while fixing #2019): only shapes are added beside the selection.
|
||
#[test]
|
||
fn an_inserted_picture_replaces_a_selected_picture() {
|
||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, wordcraft_doc::para::OBJ.len_utf8()) };
|
||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(1));
|
||
}
|
||
|
||
#[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();
|
||
// The frame keeps its size; it turns.
|
||
assert_eq!(obj_size(&selected(&s).unwrap().1), (100.0, 50.0));
|
||
s.run("picture.reset", &json!({})).unwrap();
|
||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||
// Text on one side only (#354), one undo step; an unknown side is refused.
|
||
let side = |s: &Session| selected(s).unwrap().1.frame().unwrap().2.wrap_text;
|
||
s.run("arrange.wrapText", &json!({"side": "largest"})).unwrap();
|
||
assert_eq!(side(&s), WrapText::Largest);
|
||
assert!(s.run("arrange.wrapText", &json!({"side": "middle"})).is_err());
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(side(&s), WrapText::BothSides);
|
||
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());
|
||
}
|
||
|
||
/// A `sharpen` too small to be a normal blur width must not reach the blur, which asserts on it.
|
||
#[test]
|
||
fn a_tiny_sharpen_value_does_not_panic() {
|
||
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();
|
||
for sharpen in [-1e-40_f64, -1e-45, -1e-38, 1e-40, -1e-30, 0.0, -0.5] {
|
||
s.run("picture.corrections", &json!({"sharpen": sharpen})).unwrap_or_else(|e| panic!("sharpen {sharpen}: {e:?}"));
|
||
}
|
||
}
|
||
|
||
/// Transparency is absolute from the original pixels: 80% then 0% is opaque again
|
||
/// (it used to stay faded), and repeating a value is idempotent. Corrections and color
|
||
/// re-set the same way: brightness 0 and saturation 100 restore the original.
|
||
#[test]
|
||
fn transparency_is_absolute_from_the_original() {
|
||
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();
|
||
let rgba = |s: &Session| {
|
||
let (_, o) = selected(s).unwrap();
|
||
let InlineObject::Image { media, .. } = o else { panic!("a picture") };
|
||
let bytes = s.doc.media.get(&media).cloned().unwrap();
|
||
image::load_from_memory(&bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect::<Vec<_>>()
|
||
};
|
||
let alpha = |s: &Session| rgba(s).iter().map(|p| p[3]).collect::<Vec<_>>();
|
||
let orig = rgba(&s);
|
||
assert!(orig.iter().all(|p| p[3] == 255));
|
||
s.run("picture.transparency", &json!({"percent": 80})).unwrap();
|
||
assert!(alpha(&s).iter().all(|&a| a == 50), "255 * 0.2 truncated");
|
||
s.run("picture.transparency", &json!({"percent": 0})).unwrap();
|
||
assert_eq!(rgba(&s), orig, "back to opaque");
|
||
s.run("picture.transparency", &json!({"percent": 50})).unwrap();
|
||
let once = rgba(&s);
|
||
assert!(once.iter().all(|p| p[3] == 127), "255 * 0.5");
|
||
s.run("picture.transparency", &json!({"percent": 50})).unwrap();
|
||
assert_eq!(rgba(&s), once, "repeating is idempotent");
|
||
s.run("picture.corrections", &json!({"brightness": 20})).unwrap();
|
||
assert_ne!(rgba(&s), orig);
|
||
s.run("picture.corrections", &json!({"brightness": 0})).unwrap();
|
||
assert_eq!(rgba(&s), orig, "brightness 0 restores the original");
|
||
s.run("picture.color", &json!({"mode": "saturation", "saturation": 0})).unwrap();
|
||
assert!(rgba(&s).iter().all(|p| p[0] == p[1] && p[1] == p[2]), "grayscale");
|
||
s.run("picture.color", &json!({"mode": "saturation", "saturation": 100})).unwrap();
|
||
assert!(
|
||
rgba(&s).iter().zip(orig.iter()).all(|(a, b)| a.iter().zip(b.iter()).all(|(x, y)| (*x as i16 - *y as i16).abs() <= 2)),
|
||
"saturation 100 restores within float rounding"
|
||
);
|
||
s.run("picture.reset", &json!({})).unwrap();
|
||
assert_eq!(rgba(&s), orig, "reset still finds the original");
|
||
}
|
||
|
||
/// #2052: a picture style picked after another replaces it (Thick Frame then Simple Frame is
|
||
/// only the simple frame), a border replaces the border and keeps the style, an artistic
|
||
/// effect replaces the effect, and transparency 50% then 10% is 10%, as in Word.
|
||
#[test]
|
||
fn picture_styles_borders_and_effects_replace_the_last_one() {
|
||
let picture = || {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&png(80, 40))})).unwrap();
|
||
s
|
||
};
|
||
let bitmap = |s: &Session| {
|
||
let (_, o) = selected(s).unwrap();
|
||
let InlineObject::Image { media, .. } = o else { panic!("a picture") };
|
||
image::load_from_memory(&s.doc.media.get(&media).cloned().unwrap()).unwrap().to_rgba8()
|
||
};
|
||
let after = |cmds: &[(&str, Value)]| {
|
||
let mut s = picture();
|
||
for (id, v) in cmds {
|
||
s.run(id, v).unwrap();
|
||
}
|
||
bitmap(&s)
|
||
};
|
||
let (thick, simple) = (json!({"style": "thickFrame"}), json!({"style": "simpleFrame"}));
|
||
assert_eq!(after(&[("picture.style", thick.clone()), ("picture.style", simple.clone())]), after(&[("picture.style", simple.clone())]));
|
||
let (wide, thin) = (json!({"width": 8}), json!({"width": 2}));
|
||
assert_eq!(
|
||
after(&[("picture.style", thick.clone()), ("picture.border", wide.clone()), ("picture.border", thin.clone())]),
|
||
after(&[("picture.style", thick.clone()), ("picture.border", thin.clone())]),
|
||
"a border replaces the border and keeps the style"
|
||
);
|
||
assert_eq!(
|
||
after(&[("picture.border", wide), ("picture.style", simple.clone())]),
|
||
after(&[("picture.style", simple)]),
|
||
"a style replaces the border too"
|
||
);
|
||
assert_eq!(
|
||
after(&[("picture.effects", json!({"effect": "invert"})), ("picture.effects", json!({"effect": "posterize"}))]),
|
||
after(&[("picture.effects", json!({"effect": "posterize"}))])
|
||
);
|
||
assert_eq!(after(&[("picture.effects", json!({"effect": "invert"})), ("picture.effects", json!({"effect": "none"}))]), bitmap(&picture()));
|
||
let fifty_then_ten = after(&[("picture.transparency", json!({"percent": 50})), ("picture.transparency", json!({"percent": 10}))]);
|
||
assert!(fifty_then_ten.pixels().all(|p| p.0[3] == 229), "255 * 0.9");
|
||
|
||
// Undo then a new edit reusing the drawn bitmap's media key starts from what is there.
|
||
let mut s = picture();
|
||
s.run("picture.style", &thick).unwrap();
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
s.run("picture.effects", &json!({"effect": "invert"})).unwrap();
|
||
s.run("picture.border", &json!({"width": 2})).unwrap();
|
||
let (w, h) = bitmap(&s).dimensions();
|
||
assert_eq!((w, h), (84, 44), "the effect and a 2 px border, no thick frame from before the undo");
|
||
}
|
||
|
||
/// #513: Wrap Text › Fix Position on Page / Move with Text re-anchor a floating picture
|
||
/// vertically without moving it; one undo step each; an inline picture can't.
|
||
#[test]
|
||
fn move_with_text_and_fix_position_keep_the_picture_in_place() {
|
||
use wordcraft_doc::para::Anchor;
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("text.insert", &json!({"text": "First paragraph\nSecond"})).unwrap();
|
||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||
s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap();
|
||
assert!(s.run("arrange.moveWithText", &json!({"value": false})).is_err(), "inline: no anchoring");
|
||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||
let v_rel = |s: &Session| selected(s).unwrap().1.frame().unwrap().2.v_rel;
|
||
let with_text = |s: &Session| moves_with_text(selected(s).unwrap().1.frame().unwrap().2);
|
||
let rect = |s: &mut Session| {
|
||
let pos = selected(s).unwrap().0;
|
||
s.layout().object(&pos, 0).unwrap().rect
|
||
};
|
||
let before = rect(&mut s);
|
||
s.run("arrange.moveWithText", &json!({"value": false})).unwrap();
|
||
assert_eq!(v_rel(&s), Anchor::Page);
|
||
assert!(!with_text(&s));
|
||
assert!((rect(&mut s).y - before.y).abs() < 0.01, "it stays put");
|
||
s.run("arrange.moveWithText", &json!({"value": true})).unwrap();
|
||
assert!(with_text(&s));
|
||
assert!((rect(&mut s).y - before.y).abs() < 0.01, "it stays put");
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(v_rel(&s), Anchor::Page);
|
||
}
|
||
|
||
/// #332: Rotate and Flip turn the frame (an angle and mirroring), never the pixels; an exact
|
||
/// angle is normalised; each is one undo step; the rotated frame takes its rotated room.
|
||
#[test]
|
||
fn rotate_and_flip_turn_the_frame_not_the_pixels() {
|
||
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, "width": 200})).unwrap();
|
||
let (pos, before) = selected(&s).unwrap();
|
||
let InlineObject::Image { media, .. } = &before else { panic!("a picture") };
|
||
let (media, media_count) = (media.clone(), s.doc.media.len());
|
||
let spin = |s: &Session| selected(s).unwrap().1.frame().unwrap().2.spin();
|
||
s.run("arrange.rotate", &json!({"direction": "right"})).unwrap();
|
||
assert_eq!(spin(&s).deg, 90.0);
|
||
let (_, o) = selected(&s).unwrap();
|
||
assert!(matches!(&o, InlineObject::Image { media: m, w: 200.0, h: 100.0, .. } if *m == media), "{o:?}");
|
||
assert_eq!(s.doc.media.len(), media_count, "no new bitmap");
|
||
// Inline, the line makes room for the turned picture: 100 wide, 200 tall.
|
||
let r = s.layout().object(&pos, 0).unwrap();
|
||
let b = r.bounds();
|
||
assert!((b.w - 100.0).abs() < 0.5 && (b.h - 200.0).abs() < 0.5, "{b:?}");
|
||
s.run("arrange.rotate", &json!({"direction": "left"})).unwrap();
|
||
s.run("arrange.rotate", &json!({"direction": "left"})).unwrap();
|
||
assert_eq!(spin(&s).deg, 270.0);
|
||
// A flip mirrors the page view: the angle reverses.
|
||
s.run("arrange.rotate", &json!({"direction": "flipHorizontal"})).unwrap();
|
||
assert_eq!(spin(&s), wordcraft_geom::Spin::new(90.0, true, false));
|
||
s.run("arrange.rotation", &json!({"degrees": -30.0})).unwrap();
|
||
assert_eq!(spin(&s), wordcraft_geom::Spin::new(330.0, true, false));
|
||
assert!(s.run("arrange.rotation", &json!({"degrees": "NaN"})).is_err());
|
||
assert!(s.run("arrange.rotate", &json!({"direction": "sideways"})).is_err());
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(spin(&s), wordcraft_geom::Spin::new(90.0, true, false));
|
||
// Shapes and text boxes turn too.
|
||
s.run("select.collapse", &json!({"end": true})).unwrap();
|
||
s.run("insert.shape", &json!({"kind": "textBox"})).unwrap();
|
||
s.run("arrange.rotation", &json!({"degrees": 45})).unwrap();
|
||
assert_eq!(spin(&s).deg, 45.0);
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
|
||
/// Select the objects at `at` in the list of all objects (the first as the selection, the rest added).
|
||
fn pick_objects(s: &mut Session, at: &[usize]) {
|
||
let list = all_objects(s);
|
||
let first = &list[at[0]].0;
|
||
s.sel = Selection { anchor: first.clone(), focus: Pos { off: first.off + wordcraft_doc::para::OBJ.len_utf8(), ..first.clone() } };
|
||
s.also_selected = at[1..].iter().map(|i| list[*i].0.clone()).collect();
|
||
}
|
||
|
||
#[test]
|
||
fn group_refuses_more_than_the_child_cap() {
|
||
let cap = wordcraft_doc::para::MAX_GROUP_CHILDREN;
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("document.setText", &json!({"text": "one"})).unwrap();
|
||
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
|
||
for _ in 0..=cap {
|
||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 10, "height": 10})).unwrap();
|
||
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 100})).unwrap();
|
||
s.run("select.collapse", &json!({"end": true})).unwrap();
|
||
}
|
||
let n = all_objects(&s).len();
|
||
assert_eq!(n, cap + 1);
|
||
|
||
// Flat: every shape picked.
|
||
pick_objects(&mut s, &(0..n).collect::<Vec<_>>());
|
||
assert_eq!(picked_objects(&s).len(), n);
|
||
let before = s.doc.clone();
|
||
assert!(s.run("arrange.group", &json!({})).is_err());
|
||
assert_eq!(s.doc, before);
|
||
|
||
// Nested: a group of cap - 1 and a group of 2; grouping those two would hold cap + 1 members.
|
||
pick_objects(&mut s, &(0..cap - 1).collect::<Vec<_>>());
|
||
s.run("arrange.group", &json!({})).unwrap();
|
||
let rest = all_objects(&s).len();
|
||
assert_eq!(rest, 3);
|
||
pick_objects(&mut s, &[1, 2]);
|
||
s.run("arrange.group", &json!({})).unwrap();
|
||
assert_eq!(all_objects(&s).len(), 2);
|
||
pick_objects(&mut s, &[0, 1]);
|
||
assert_eq!(picked_objects(&s).len(), 2);
|
||
let before = s.doc.clone();
|
||
assert!(s.run("arrange.group", &json!({})).is_err());
|
||
assert_eq!(s.doc, before);
|
||
}
|
||
|
||
/// Page 1 as exported (`file.exportPng` at scale 1), its pixels.
|
||
fn page_pixels(s: &Session) -> Vec<u8> {
|
||
let l = s.export_layout();
|
||
wordcraft_render::render_page(&s.doc, &l.pages[0], 1.0, &Default::default()).pixels
|
||
}
|
||
|
||
/// How many pixels of two renders clearly differ (not just in antialiasing).
|
||
fn pixels_differing(a: &[u8], b: &[u8]) -> usize {
|
||
assert_eq!(a.len(), b.len());
|
||
a.chunks(4).zip(b.chunks(4)).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 96)).count()
|
||
}
|
||
|
||
/// A rectangle at (100, 100) and an arrow at (250, 100), both 80 × 40, on a blank page; the
|
||
/// arrow turned `arrow_deg`.
|
||
fn rectangle_and_arrow(arrow_deg: f32) -> Session {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 80, "height": 40})).unwrap();
|
||
s.run("arrange.bounds", &json!({"x": 100, "y": 100})).unwrap();
|
||
s.run("select.collapse", &json!({"end": true})).unwrap();
|
||
s.run("insert.shape", &json!({"kind": "arrow", "width": 80, "height": 40})).unwrap();
|
||
s.run("arrange.bounds", &json!({"x": 250, "y": 100})).unwrap();
|
||
s.run("arrange.rotation", &json!({"degrees": arrow_deg})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn select_both(s: &mut Session) {
|
||
s.run("select.objects", &json!({"index": 0})).unwrap();
|
||
s.run("select.addObject", &json!({"index": 1})).unwrap();
|
||
}
|
||
|
||
/// #441: grouping keeps a turned member's orientation, through save and reopen too.
|
||
#[test]
|
||
fn group_keeps_a_rotated_members_orientation() {
|
||
for deg in [90.0, 0.0] {
|
||
let mut s = rectangle_and_arrow(deg);
|
||
let before = page_pixels(&s);
|
||
select_both(&mut s);
|
||
s.run("arrange.group", &json!({})).unwrap();
|
||
let (_, g) = selected(&s).unwrap();
|
||
let InlineObject::Group { children, float, .. } = &g else { panic!("a group: {g:?}") };
|
||
assert_eq!(children.len(), 2, "both members stay shapes");
|
||
assert!(children.iter().all(|c| matches!(c.obj, InlineObject::Shape { .. })));
|
||
let rots: Vec<f32> = children.iter().filter_map(|c| c.obj.frame().map(|(_, _, f)| f.rot)).collect();
|
||
assert_eq!(rots, vec![0.0, deg], "the rectangle unturned, the arrow keeps its turn");
|
||
assert_eq!(float.rot, 0.0, "the group itself is unturned");
|
||
assert!(pixels_differing(&before, &page_pixels(&s)) < 20, "{deg}°: grouping changes nothing on the page");
|
||
// Saved and reopened, it looks the same.
|
||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||
let reopened = Session::new(back);
|
||
assert!(pixels_differing(&before, &page_pixels(&reopened)) < 20, "{deg}°: reopened");
|
||
// Undo gives back the two objects.
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(all_objects(&s).len(), 2);
|
||
assert!(pixels_differing(&before, &page_pixels(&s)) < 20);
|
||
}
|
||
}
|
||
|
||
/// #442: Size resizes a selected group, its members scaled with it, as Move or Resize does.
|
||
#[test]
|
||
fn size_resizes_a_group() {
|
||
let grouped = || {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 80, "height": 40})).unwrap();
|
||
s.run("arrange.bounds", &json!({"x": 100, "y": 100})).unwrap();
|
||
s.run("select.collapse", &json!({"end": true})).unwrap();
|
||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 80, "height": 40})).unwrap();
|
||
s.run("arrange.bounds", &json!({"x": 250, "y": 100})).unwrap();
|
||
s.run("select.addObject", &json!({"index": 0})).unwrap();
|
||
s.run("arrange.group", &json!({})).unwrap();
|
||
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap()[0]["size"], json!([230.0, 40.0]));
|
||
s
|
||
};
|
||
let mut sized = grouped();
|
||
sized.run("picture.size", &json!({"width": 400, "height": 200})).unwrap();
|
||
let list = sized.run("arrange.selectionPane", &json!({})).unwrap();
|
||
assert_eq!((list.as_array().unwrap().len(), &list[0]["kind"], &list[0]["size"]), (1, &json!("group"), &json!([400.0, 200.0])));
|
||
let mut moved = grouped();
|
||
moved.run("arrange.bounds", &json!({"width": 400, "height": 200})).unwrap();
|
||
let (_, a) = selected(&sized).unwrap();
|
||
let (_, b) = selected(&moved).unwrap();
|
||
assert_eq!(a, b, "the same group, members and all, as Move or Resize makes");
|
||
let InlineObject::Group { children, .. } = &a else { panic!() };
|
||
assert_eq!(children.len(), 2);
|
||
let members: Vec<[f32; 4]> = a.group_rects(0.0, 0.0, 400.0, 200.0).into_iter().map(|(r, _)| r).collect();
|
||
assert!(near(members[1], [400.0 * 150.0 / 230.0, 0.0, 400.0 * 80.0 / 230.0, 200.0]), "scaled with the group: {members:?}");
|
||
}
|
||
|
||
/// #443: ungrouping a turned group keeps its members where, and as turned as, it showed them.
|
||
#[test]
|
||
fn ungroup_keeps_a_rotated_groups_arrangement() {
|
||
for turn in [true, false] {
|
||
let mut s = rectangle_and_arrow(0.0);
|
||
select_both(&mut s);
|
||
s.run("arrange.group", &json!({})).unwrap();
|
||
if turn {
|
||
s.run("arrange.rotation", &json!({"degrees": 90})).unwrap();
|
||
}
|
||
let before = page_pixels(&s);
|
||
s.run("arrange.ungroup", &json!({})).unwrap();
|
||
let list = all_objects(&s);
|
||
assert_eq!(list.len(), 2, "two separate objects");
|
||
let want = if turn { 90.0 } else { 0.0 };
|
||
assert!(list.iter().all(|(_, o)| o.frame().is_some_and(|(_, _, f)| f.rot == want)));
|
||
assert!(pixels_differing(&before, &page_pixels(&s)) < 20, "turned {turn}: ungrouping changes nothing on the page");
|
||
if turn {
|
||
// The rectangle is above the arrow now, as the turned group showed it.
|
||
let r: Vec<[f32; 4]> = list.iter().map(|(p, _)| rect_of(&mut s, p)).collect();
|
||
assert!((r[0][0] - r[1][0]).abs() < 0.5 && r[0][1] < r[1][1], "{r:?}");
|
||
}
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert!(matches!(all_objects(&s).as_slice(), [(_, InlineObject::Group { .. })]));
|
||
assert_eq!(pixels_differing(&before, &page_pixels(&s)), 0);
|
||
}
|
||
}
|
||
|
||
#[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]);
|
||
}
|
||
|
||
/// #2573: Size from the original. `original` alone gives the picture back its original size
|
||
/// (its pixels at 96 ppi, less what is cropped) and `scale` is a percentage of it; Compress
|
||
/// Pictures doesn't change what the original is. Only a picture has one.
|
||
#[test]
|
||
fn picture_size_counts_from_the_original() {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
let data = super::super::insert::base64_encode(&png(40, 20));
|
||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||
let size = |s: &Session| obj_size(&selected(s).unwrap().1);
|
||
assert_eq!(size(&s), (30.0, 15.0), "40×20 px at 96 ppi");
|
||
s.run("picture.size", &json!({"width": 300.0, "height": 100.0, "lockAspect": false})).unwrap();
|
||
s.run("picture.compress", &json!({"maxPixels": 32})).unwrap();
|
||
let Some((_, InlineObject::Image { media, .. })) = selected(&s) else { panic!("a picture") };
|
||
assert_eq!(wordcraft_render::image_size(&s.doc.media[&media]), Some((32, 16)), "compressed");
|
||
assert_eq!(original_size(&s, &media), Some(OriginalSize { w: 30.0, h: 15.0, pixels: Some((40, 20)) }));
|
||
s.run("picture.size", &json!({"original": true})).unwrap();
|
||
assert_eq!(size(&s), (30.0, 15.0));
|
||
s.run("picture.size", &json!({"original": true, "scale": 250})).unwrap();
|
||
assert_eq!(size(&s), (75.0, 37.5));
|
||
// Half the width cropped away: half the original width is left.
|
||
s.run("picture.crop", &json!({"left": 0.25, "right": 0.25})).unwrap();
|
||
s.run("picture.size", &json!({"original": true})).unwrap();
|
||
assert_eq!(size(&s), (15.0, 15.0));
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(size(&s), (75.0, 37.5), "one undo step");
|
||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||
assert!(s.run("picture.size", &json!({"original": true})).is_err(), "a shape has no original size");
|
||
}
|
||
|
||
/// #2573: a size past 4000 pt (or under 1 pt) that keeps the proportions scales as a whole
|
||
/// instead of flattening against the limit; both sides given are clamped each on its own.
|
||
#[test]
|
||
fn picture_size_past_the_limit_keeps_the_proportions() {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
let data = super::super::insert::base64_encode(&png(40, 20));
|
||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||
let size = |s: &Session| obj_size(&selected(s).unwrap().1);
|
||
s.run("picture.size", &json!({"width": 9000.0})).unwrap();
|
||
assert_eq!(size(&s), (4000.0, 2000.0));
|
||
s.run("picture.size", &json!({"scale": 1000})).unwrap();
|
||
assert_eq!(size(&s), (4000.0, 2000.0));
|
||
s.run("picture.size", &json!({"width": 9000.0, "height": 10.0})).unwrap();
|
||
assert_eq!(size(&s), (4000.0, 10.0));
|
||
s.run("picture.size", &json!({"height": 30.0, "lockAspect": false})).unwrap();
|
||
assert_eq!(size(&s), (4000.0, 30.0));
|
||
// Below 1 pt as well: a 1% scale of 50×200 pt is 1×4 pt, not 1×2.
|
||
s.run("picture.size", &json!({"width": 50.0, "height": 200.0})).unwrap();
|
||
s.run("picture.size", &json!({"scale": 1})).unwrap();
|
||
assert_eq!(size(&s), (1.0, 4.0));
|
||
// Too long and thin for one factor: side by side. NaN and infinity stay in range.
|
||
assert_eq!(fit_size(0.5, 5000.0, true), (1.0, 4000.0));
|
||
assert_eq!(fit_size(f32::NAN, 10.0, true), (1.0, 10.0));
|
||
assert_eq!(fit_size(f32::INFINITY, 10.0, true), (4000.0, 10.0));
|
||
// Hostile values never leave the 1–4000 pt range.
|
||
for v in [json!({"scale": -50}), json!({"width": -1.0}), json!({"original": "yes", "scale": 1e30}), json!({"height": 1e30})] {
|
||
s.run("picture.size", &v).unwrap();
|
||
let (w, h) = size(&s);
|
||
assert!((1.0..=4000.0).contains(&w) && (1.0..=4000.0).contains(&h), "{v}: {w}×{h}");
|
||
}
|
||
}
|
||
|
||
/// A crop handle drop: `picture.crop` with the new size is one edit, one undo step.
|
||
#[test]
|
||
fn picture_crop_with_size_is_one_undo_step() {
|
||
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.size", &json!({"width": 200.0, "height": 100.0, "lockAspect": false})).unwrap();
|
||
let image = |s: &Session| match selected(s) {
|
||
Some((_, InlineObject::Image { w, h, crop, .. })) => (w, h, crop),
|
||
other => panic!("expected image, got {other:?}"),
|
||
};
|
||
s.run("picture.crop", &json!({"left": 0.25, "top": 0.0, "right": 0.0, "bottom": 0.1, "width": 150.0, "height": 90.0})).unwrap();
|
||
assert_eq!(image(&s), (150.0, 90.0, [0.25, 0.0, 0.0, 0.1]));
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(image(&s), (200.0, 100.0, [0.0; 4]));
|
||
// Clamps: each side at most 0.95; hostile sizes are ignored or clamped, never applied raw.
|
||
s.run("picture.crop", &json!({"left": 9.0, "right": -3.0, "width": "x", "height": -5.0})).unwrap();
|
||
let (w, h, crop) = image(&s);
|
||
assert_eq!(crop, [0.95, 0.0, 0.0, 0.0]);
|
||
assert_eq!(w, 200.0);
|
||
assert!(h >= 1.0, "{h}");
|
||
}
|
||
|
||
/// #3135: a picture can be cropped to any part of itself, not just its middle. A side goes to
|
||
/// 0.95 (5% stays, as the renderer draws), and the two sides of an axis keep their 5%.
|
||
#[test]
|
||
fn a_picture_can_be_cropped_to_any_part_of_itself() {
|
||
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.size", &json!({"width": 200.0, "height": 100.0, "lockAspect": false})).unwrap();
|
||
let crop = |s: &Session| match selected(s) {
|
||
Some((_, InlineObject::Image { crop, .. })) => crop,
|
||
other => panic!("expected image, got {other:?}"),
|
||
};
|
||
let left = |s: &Session| {
|
||
let (_, o) = selected(s).unwrap();
|
||
super::cropped_original_size(s, &o).unwrap().0
|
||
};
|
||
// 90% off the left keeps the rightmost tenth: the side is not held at 45%.
|
||
s.run("picture.crop", &json!({"left": 0.9})).unwrap();
|
||
assert_eq!(crop(&s), [0.9, 0.0, 0.0, 0.0]);
|
||
assert!((left(&s) - 1.5).abs() < 1e-3, "a tenth of the 15 pt original: {}", left(&s));
|
||
// With the left at 0.9 the right can take only 5% more, and what the crop leaves of the
|
||
// original follows the crop down to that 5%.
|
||
s.run("picture.crop", &json!({"left": 0.9, "right": 0.9})).unwrap();
|
||
let c = crop(&s);
|
||
assert!((c[0] - 0.9).abs() < 1e-6 && (c[2] - 0.05).abs() < 1e-5 && c[1] == 0.0 && c[3] == 0.0, "{c:?}");
|
||
assert!((left(&s) - 0.75).abs() < 1e-3, "5% of the 15 pt original: {}", left(&s));
|
||
// A single side past the cap stops at 0.95.
|
||
s.run("picture.crop", &json!({"right": 4.0})).unwrap();
|
||
assert_eq!(crop(&s), [0.0, 0.0, 0.95, 0.0]);
|
||
}
|
||
|
||
/// Cropping a floating picture's left or top with its new size keeps the visible part where
|
||
/// it is on the page: the frame moves by the trimmed amount, in the same undo step.
|
||
#[test]
|
||
fn cropping_a_floating_picture_keeps_the_visible_part_in_place() {
|
||
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.size", &json!({"width": 200.0, "height": 100.0, "lockAspect": false})).unwrap();
|
||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||
let at = |s: &Session| match selected(s) {
|
||
Some((_, InlineObject::Image { float, .. })) => (float.x, float.y),
|
||
other => panic!("expected image, got {other:?}"),
|
||
};
|
||
let (x0, y0) = at(&s);
|
||
s.run("picture.crop", &json!({"left": 0.25, "top": 0.1, "width": 150.0, "height": 90.0})).unwrap();
|
||
let (x1, y1) = at(&s);
|
||
assert!((x1 - (x0 + 50.0)).abs() < 1e-3 && (y1 - (y0 + 10.0)).abs() < 1e-3, "{x0},{y0} -> {x1},{y1}");
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(at(&s), (x0, y0));
|
||
}
|
||
|
||
/// Flipped left to right, the source's right edge is on the page's left: cropping it moves
|
||
/// the frame; cropping the source's left (now on the right) doesn't.
|
||
#[test]
|
||
fn cropping_a_flipped_floating_picture_moves_it_by_the_side_on_its_left() {
|
||
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.size", &json!({"width": 200.0, "height": 100.0, "lockAspect": false})).unwrap();
|
||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||
s.run("arrange.rotation", &json!({"degrees": 0.0, "flipH": true, "flipV": true})).unwrap();
|
||
let at = |s: &Session| match selected(s) {
|
||
Some((_, InlineObject::Image { float, .. })) => (float.x, float.y),
|
||
other => panic!("expected image, got {other:?}"),
|
||
};
|
||
let (x0, y0) = at(&s);
|
||
s.run("picture.crop", &json!({"right": 0.25, "bottom": 0.1, "width": 150.0, "height": 90.0})).unwrap();
|
||
let (x1, y1) = at(&s);
|
||
assert!((x1 - (x0 + 50.0)).abs() < 1e-3 && (y1 - (y0 + 10.0)).abs() < 1e-3, "{x0},{y0} -> {x1},{y1}");
|
||
s.run("picture.crop", &json!({"left": 0.25, "right": 0.25, "bottom": 0.1, "width": 100.0, "height": 90.0})).unwrap();
|
||
assert!((at(&s).0 - x1).abs() < 1e-3, "the source's left is on the right: no move");
|
||
}
|
||
|
||
/// A header picture resizes and drags like a body one: the move stays in the header story
|
||
/// and lands where dropped; the header hit test finds it.
|
||
#[test]
|
||
fn header_pictures_can_be_resized_and_dragged() {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("insert.editHeader", &json!({})).unwrap();
|
||
let story = s.sel.focus.story;
|
||
assert!(matches!(story, StoryRef::Part(_)), "editing the header, got {story:?}");
|
||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||
s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap();
|
||
let (pos, _) = selected(&s).expect("a new header picture is selected");
|
||
assert_eq!(pos.story, story);
|
||
s.run("arrange.bounds", &json!({"width": 160, "height": 90})).unwrap();
|
||
let r = s.run("arrange.bounds", &json!({"page": 0, "x": 200, "y": 60})).unwrap();
|
||
assert_eq!(r["object"]["float"]["wrap"], "square");
|
||
let o = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out");
|
||
assert_eq!(o.story, story);
|
||
assert!((o.rect.x - 200.0).abs() < 1.0 && (o.rect.y - 60.0).abs() < 1.0, "{:?}", o.rect);
|
||
let hit = s.layout().object_at_in(o.page, o.rect.x + 5.0, o.rect.y + 5.0, 4.0, story).expect("header hit");
|
||
assert_eq!(hit.pos(), o.pos());
|
||
s.run("arrange.nudge", &json!({"dx": 6, "dy": 4})).unwrap();
|
||
let n = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out");
|
||
assert!((n.rect.x - 206.0).abs() < 1.0 && (n.rect.y - 64.0).abs() < 1.0, "{:?}", n.rect);
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
let u = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out");
|
||
assert!((u.rect.x - 200.0).abs() < 1.0 && (u.rect.y - 60.0).abs() < 1.0, "{:?}", u.rect);
|
||
}
|
||
|
||
/// Shape Format › Text (#369): direction and alignment on the box the caret is in, then
|
||
/// Create Link to an empty box and Break Link, each one undo step.
|
||
#[test]
|
||
fn text_direction_alignment_and_links() {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("insert.textBox", &json!({"text": "Some text that flows on"})).unwrap();
|
||
let a = text_box_target(&s).unwrap().1;
|
||
s.run("shape.textDirection", &json!({"dir": "vert270"})).unwrap();
|
||
s.run("shape.alignText", &json!({"anchor": "ctr"})).unwrap();
|
||
let body = |s: &Session, id: u32| s.doc.parts.get(&id).unwrap().body;
|
||
assert_eq!((body(&s, a).vert, body(&s, a).anchor), (TextVert::Vert270, VAlign::Center));
|
||
assert!(s.run("shape.textDirection", &json!({"dir": "sideways"})).is_err());
|
||
assert_eq!(body(&s, a).vert, TextVert::Vert270, "a bad value changes nothing");
|
||
|
||
// A second, empty box after the first; link the first to it by its pane index.
|
||
s.run("caret.set", &json!({"pos": Pos::body(0, 3)})).unwrap();
|
||
s.run("insert.textBox", &json!({})).unwrap();
|
||
let b = text_box_target(&s).unwrap().1;
|
||
s.run("select.objects", &json!({"index": 0})).unwrap();
|
||
assert_eq!(text_box_target(&s).map(|t| t.1), Some(a));
|
||
s.run("shape.link", &json!({"target": 1})).unwrap();
|
||
assert_eq!(s.doc.text_box_chains(), vec![vec![a, b]]);
|
||
assert!(can_link(&s).is_some(), "linked once: Break Link first");
|
||
// The second box shows the first one's story.
|
||
let l = s.layout();
|
||
assert_eq!(
|
||
l.pages[0].items.iter().filter(|it| matches!(it, wordcraft_layout::Placed::Object { text_box: Some(t), .. } if *t == a)).count(),
|
||
2
|
||
);
|
||
// Linking back to the start, or to a box with text, is refused.
|
||
s.run("select.objects", &json!({"index": 1})).unwrap();
|
||
assert!(s.run("shape.link", &json!({"target": 0})).is_err());
|
||
s.run("select.objects", &json!({"index": 0})).unwrap();
|
||
s.run("shape.breakLink", &json!({})).unwrap();
|
||
assert!(s.doc.text_box_chains().is_empty());
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.doc.text_box_chains(), vec![vec![a, b]]);
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert!(s.doc.text_box_chains().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn shape_alt_text_and_decorative_are_one_undo_step_each() {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("insert.shape", &json!({"kind": "ellipse"})).unwrap();
|
||
s.run("object.altText", &json!({"text": "A green circle", "title": "Logo"})).unwrap();
|
||
let (_, o) = selected(&s).unwrap();
|
||
assert_eq!((o.alt_text(), o.frame().map(|f| f.2.alt.title.as_str()), o.is_decorative()), ("A green circle", Some("Logo"), false));
|
||
s.run("object.altText", &json!({"decorative": true})).unwrap();
|
||
assert!(selected(&s).unwrap().1.is_decorative());
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
let (_, o) = selected(&s).unwrap();
|
||
assert!(!o.is_decorative() && o.alt_text() == "A green circle");
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(selected(&s).unwrap().1.alt_text(), "");
|
||
// Hostile input is capped, and nothing to set is an error.
|
||
s.run("object.altText", &json!({"text": "x".repeat(50_000)})).unwrap();
|
||
assert_eq!(selected(&s).unwrap().1.alt_text().len(), wordcraft_doc::para::MAX_ALT_TEXT);
|
||
assert!(s.run("object.altText", &json!({})).is_err());
|
||
// A picture-only alias stays a picture-only alias.
|
||
assert!(s.run("picture.altText", &json!({"text": "no"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn table_alt_text_sets_title_and_description() {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
|
||
s.run("table.altText", &json!({"title": "Prices", "text": "Prices by year"})).unwrap();
|
||
let t = s.doc.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.clone()) } else { None }).unwrap();
|
||
assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), Some("Prices by year")));
|
||
s.run("table.altText", &json!({"text": ""})).unwrap();
|
||
let t = s.doc.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.clone()) } else { None }).unwrap();
|
||
assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), None));
|
||
}
|
||
|
||
/// The lines of `page` beside `r` vertically (top above its bottom, bottom below its top).
|
||
fn lines_beside(s: &mut Session, page: usize, r: wordcraft_geom::Rect) -> Vec<(f32, f32, f32)> {
|
||
let layout = s.layout();
|
||
wordcraft_layout::hit::page_lines(&layout.pages[page], StoryRef::Body)
|
||
.iter()
|
||
.filter(|l| l.top < r.bottom() - 0.5 && l.bottom > r.y + 0.5)
|
||
.map(|l| (l.top, l.left, l.right))
|
||
.collect()
|
||
}
|
||
|
||
/// #884: a Square-wrapped shape dragged across a two-column page: the text in whichever
|
||
/// column it lands in wraps around it there, and its old place closes up.
|
||
#[test]
|
||
fn dragged_square_shape_wraps_text_in_its_new_column() {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||
for i in 0..40 {
|
||
if i > 0 {
|
||
s.run("text.newParagraph", &json!({})).unwrap();
|
||
}
|
||
s.run("text.insert", &json!({"text": format!("Paragraph {i} has words enough to run over two or three lines of a narrow column.")}))
|
||
.unwrap();
|
||
}
|
||
s.run("caret.docStart", &json!({})).unwrap();
|
||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||
s.run("arrange.bounds", &json!({"width": 80, "height": 60})).unwrap();
|
||
let cols: Vec<(f32, f32)> = {
|
||
let layout = s.layout();
|
||
let mut v: Vec<(f32, f32)> =
|
||
wordcraft_layout::hit::page_lines(&layout.pages[0], StoryRef::Body).iter().map(|l| (l.left, l.right)).collect();
|
||
v.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
v.dedup_by(|a, b| (a.0 - b.0).abs() < 1.0);
|
||
v
|
||
};
|
||
assert_eq!(cols.len(), 2, "two columns: {cols:?}");
|
||
let mid = |c: (f32, f32)| (c.0 + c.1) / 2.0 - 40.0;
|
||
let wrapped = |s: &mut Session, r: wordcraft_geom::Rect| {
|
||
let beside = lines_beside(s, 0, r);
|
||
assert!(!beside.is_empty(), "text beside {r:?}");
|
||
for (top, left, right) in beside {
|
||
assert!(right <= r.x + 0.5 || left >= r.right() - 0.5, "line at {top} ({left}..{right}) runs into {r:?}");
|
||
}
|
||
};
|
||
let full = |s: &mut Session, r: wordcraft_geom::Rect, c: (f32, f32)| {
|
||
for (top, left, right) in lines_beside(s, 0, r) {
|
||
if left < c.1 && right > c.0 {
|
||
assert!((left - c.0).abs() < 0.5 && (right - c.1).abs() < 0.5, "line at {top} ({left}..{right}) still kept clear in {c:?}");
|
||
}
|
||
}
|
||
};
|
||
// Into the second column, then back into the first, then again to the second, lower.
|
||
for (col, y, from) in [(1, 300.0, None), (0, 200.0, Some(1)), (1, 400.0, Some(0))] {
|
||
let old = object_area(&mut s).rect;
|
||
s.run("arrange.bounds", &json!({"page": 0, "x": mid(cols[col]), "y": y})).unwrap();
|
||
let o = object_area(&mut s);
|
||
assert_eq!((o.page, o.rect.x, o.rect.y), (0, mid(cols[col]), y), "dropped where asked");
|
||
wrapped(&mut s, o.rect);
|
||
if let Some(c) = from {
|
||
full(&mut s, old, cols[c]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Two columns of `n` paragraphs.
|
||
fn two_column_doc(n: usize) -> Session {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||
for i in 0..n {
|
||
if i > 0 {
|
||
s.run("text.newParagraph", &json!({})).unwrap();
|
||
}
|
||
let text = format!("Paragraph {i} has words enough to run over several lines of a narrow column, and then some more words. ");
|
||
s.run("text.insert", &json!({"text": text.repeat(3)})).unwrap();
|
||
}
|
||
s
|
||
}
|
||
|
||
/// The page's columns (left, right) from its section's geometry.
|
||
fn page_columns(s: &mut Session, page: usize) -> Vec<(f32, f32)> {
|
||
let layout = s.layout();
|
||
let pg = &layout.pages[page];
|
||
let boxes = s.doc.sections()[pg.section].1.column_boxes();
|
||
boxes.iter().map(|(x, w)| (pg.body.x + x, pg.body.x + x + w)).collect()
|
||
}
|
||
|
||
/// #884 review: mirrored indents shift some paragraphs on even pages; that shift isn't taken
|
||
/// for a column of its own, so the drop anchors to the paragraph under it, mirrored or not.
|
||
#[test]
|
||
fn mirrored_indents_make_no_extra_column() {
|
||
let mut s = two_column_doc(40);
|
||
for i in (1..40).step_by(2) {
|
||
let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap();
|
||
p.props.mirror_indents = Some(true);
|
||
p.props.indent_right = Some(36.0);
|
||
p.touch();
|
||
}
|
||
s.touch();
|
||
assert!(s.layout().pages.len() > 1);
|
||
let cols = page_columns(&mut s, 1);
|
||
// On page 2, a plain paragraph starting in the first column below a mirrored one.
|
||
let layout = s.layout();
|
||
let starts: Vec<(usize, f32, f32)> = layout.pages[1]
|
||
.items
|
||
.iter()
|
||
.filter_map(|it| match it {
|
||
wordcraft_layout::Placed::Lines { path, l0: 0, x, y, .. } if path.depth() == 0 && *x < cols[1].0 - 0.5 => Some((path.last(), *x, *y)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert!(starts.iter().any(|p| p.1 > cols[0].0 + 1.0), "mirrored paragraphs shift right: {starts:?}");
|
||
let &(plain, _, top) = starts.iter().skip(1).find(|p| p.0 % 2 == 0).unwrap();
|
||
let mid = (cols[0].0 + cols[0].1) / 2.0;
|
||
let got = anchor_paragraph(&s.doc, &layout, 1, (mid, top + 1.0), StoryRef::Body);
|
||
assert_eq!(got, Some(wordcraft_doc::Path::top(plain)));
|
||
}
|
||
|
||
/// A Word file whose only paragraph holds a group of a rectangle and Word's own ink (with its
|
||
/// picture fallback).
|
||
fn group_with_word_ink() -> Vec<u8> {
|
||
use std::io::Write;
|
||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk""#;
|
||
let rect = r#"<wps:wsp><wps:cNvPr id="2" name="Box"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>"#;
|
||
let ink = r#"<mc:AlternateContent><mc:Choice Requires="wpi"><w14:contentPart bwMode="auto" r:id="rInk"><w14:nvContentPartPr><w14:cNvPr id="3" name="Ink 3"/><w14:cNvContentPartPr/></w14:nvContentPartPr><w14:xfrm><a:off x="914400" y="0"/><a:ext cx="914400" cy="457200"/></w14:xfrm></w14:contentPart></mc:Choice><mc:Fallback><pic:pic><pic:nvPicPr><pic:cNvPr id="3" name="Ink 3"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rPreview"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="914400" y="0"/><a:ext cx="914400" cy="457200"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></mc:Fallback></mc:AlternateContent>"#;
|
||
let group = format!(
|
||
r#"<w:drawing><wp:anchor behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1" relativeHeight="1" simplePos="0" distT="0" distB="0" distL="0" distR="0"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="1828800" cy="914400"/><wp:wrapNone/><wp:docPr id="1" name="Group 1"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/><a:chOff x="0" y="0"/><a:chExt cx="1828800" cy="914400"/></a:xfrm></wpg:grpSpPr>{rect}{ink}</wpg:wgp></a:graphicData></a:graphic></wp:anchor></w:drawing>"#
|
||
);
|
||
let rel = |id: &str, ty: &str, target: &str| {
|
||
format!(r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/{ty}" Target="{target}"/>"#)
|
||
};
|
||
let parts = [
|
||
("_rels/.rels", format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{}</Relationships>"#, rel("rId1", "officeDocument", "word/document.xml"))),
|
||
("[Content_Types].xml", r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/ink/ink1.xml" ContentType="application/inkml+xml"/><Default Extension="png" ContentType="image/png"/></Types>"#.to_string()),
|
||
("word/document.xml", format!(r#"<w:document {ns}><w:body><w:p><w:r>{group}</w:r></w:p></w:body></w:document>"#)),
|
||
(
|
||
"word/_rels/document.xml.rels",
|
||
format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{}{}</Relationships>"#, rel("rInk", "customXml", "ink/ink1.xml"), rel("rPreview", "image", "media/preview.png")),
|
||
),
|
||
("word/ink/ink1.xml", r#"<ink xmlns="http://www.w3.org/2003/InkML"><trace>0 0, 1 1</trace></ink>"#.to_string()),
|
||
];
|
||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||
for (name, xml) in parts {
|
||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||
zw.write_all(xml.as_bytes()).unwrap();
|
||
}
|
||
zw.start_file("word/media/preview.png", zip::write::SimpleFileOptions::default()).unwrap();
|
||
zw.write_all(&png(8, 4)).unwrap();
|
||
zw.finish().unwrap().into_inner()
|
||
}
|
||
|
||
fn ink_members(doc: &wordcraft_doc::Document) -> usize {
|
||
let groups: Vec<_> = doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect();
|
||
let nested = groups
|
||
.iter()
|
||
.filter_map(|o| match o {
|
||
InlineObject::Group { children, .. } => Some(children.iter().filter(|c| matches!(c.obj, InlineObject::Graphic { .. })).count()),
|
||
_ => None,
|
||
})
|
||
.sum::<usize>();
|
||
let alone = groups.iter().filter(|o| matches!(o, InlineObject::Graphic { .. })).count();
|
||
nested + alone
|
||
}
|
||
|
||
/// Gap 2 review: a group holding Word's own ink can't be ungrouped (its ink can't stand alone
|
||
/// yet), so open → Ungroup → save → reopen keeps the ink in the group with its part.
|
||
#[test]
|
||
fn ungroup_refuses_a_group_holding_word_ink_and_the_ink_survives_saving() {
|
||
use std::io::Read;
|
||
let mut s = Session::new(wordcraft_docx::read(&group_with_word_ink()).unwrap());
|
||
assert_eq!(ink_members(&s.doc), 1, "the fixture's ink is read into the group");
|
||
let pos = Pos::body(0, 0);
|
||
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos } };
|
||
assert!(matches!(selected(&s), Some((_, InlineObject::Group { .. }))));
|
||
let before = wordcraft_docx::write(&s.doc).unwrap();
|
||
assert!(has_group(&s).is_some(), "Ungroup is greyed out");
|
||
let err = s.run("arrange.ungroup", &json!({})).unwrap_err();
|
||
assert!(matches!(err, CmdError::Disabled(_)), "{err:?}");
|
||
let err = ungroup(&mut s, &json!({})).unwrap_err();
|
||
assert!(matches!(err, CmdError::Disabled(_)), "{err:?}");
|
||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap();
|
||
let mut xml = String::new();
|
||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||
assert_eq!(xml.matches("<w14:contentPart").count(), 1, "{xml}");
|
||
assert!(xml.contains("<mc:Fallback>"), "{xml}");
|
||
assert_eq!(bytes, before, "the refused ungroup leaves the document unchanged");
|
||
let names: Vec<String> = z.file_names().map(String::from).collect();
|
||
assert_eq!(names.iter().filter(|n| n.starts_with("word/ink/")).count(), 1, "{names:?}");
|
||
let mut rels = String::new();
|
||
z.by_name("word/_rels/document.xml.rels").unwrap().read_to_string(&mut rels).unwrap();
|
||
assert_eq!(rels.matches("/customXml\"").count(), 1, "one ink relationship: {rels}");
|
||
let back = wordcraft_docx::read(&bytes).unwrap();
|
||
assert_eq!(ink_members(&back), 1);
|
||
}
|
||
|
||
/// Review: only Word's ink pins a group; a group of charts or diagrams ungroups as before.
|
||
#[test]
|
||
fn only_ink_members_pin_a_group() {
|
||
let s = Session::new(wordcraft_docx::read(&group_with_word_ink()).unwrap());
|
||
let pos = Pos::body(0, 0);
|
||
let mut s = s;
|
||
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos } };
|
||
let Some((_, mut group)) = selected(&s) else { panic!("no group") };
|
||
assert!(holds_ink(&group));
|
||
let InlineObject::Group { children, .. } = &mut group else { panic!("not a group") };
|
||
for c in children.iter_mut() {
|
||
if let InlineObject::Graphic { graphic, .. } = &mut c.obj {
|
||
std::sync::Arc::make_mut(graphic).kind = GraphicKind::Chart;
|
||
}
|
||
}
|
||
assert!(!holds_ink(&group), "a group of charts is not pinned");
|
||
}
|
||
}
|