Compare commits

..
Author SHA1 Message Date
Martin BallandClaude Sonnet 5.5 4ebfc3c2a7 #3518: whole-document OK sends only what was changed in the Language dialog
With "Apply to the whole document" ticked, OK sends noProof only if the user
changed it from the dialog's opening value, and lang if changed or if neither
changed (never the engine's en-US default). Heading reads "Mark the whole
document as:" while ticked. Glossary walker uses doc's PARA_WALK_DEPTH (now pub).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 16:09:02 +00:00
Martin BallandClaude Sonnet 5.5 c09da43475 #3518: Review › Language can set the whole document's proofing language
review.language takes scope "selection" (default, unchanged) or "document":
every run and paragraph mark of every story (body, headers, footers, notes,
comments, text boxes, table cells, glossary) takes lang/noProof in one undo
step, selection unchanged, pending typing format follows. The Language dialog
gets an "Apply to the whole document" check box.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 15:30:26 +00:00
Martin BallandOPUZZLES 729cd0a5fc Typing: avoid repeated scans at protected typing boundaries (#3614) (#4754)
Lands #4754.

Co-Authored-By: OPUZZLES <109076338+OPUZZLES@users.noreply.github.com>
2026-11-06 16:07:28 +01:00
Martin BallandRatkoR 8c46f4fc1b Pictures: a bold insertion cursor while dragging an inline picture (#4195) (#4208)
Lands #4208.

Co-Authored-By: RatkoR <6648783+RatkoR@users.noreply.github.com>
2026-11-06 15:53:31 +01:00
FredApps 2a0a20aec5 Merge pull request #5776 from storytold/house-rules
Copy the shared house rules into AGENTS.md
2026-11-06 14:59:01 +01:00
Martin Ball 247c7feaab i18n: craft-i18n pin follows its main (watermark strings, lv, fr, ar rows) (#5787) 2026-11-06 13:19:38 +01:00
Martin BallandRatkoR 8833c604e0 Layout: a shape dropped just after a page break stays on the new page (#4262) (#4297)
Lands #4297.

Co-Authored-By: RatkoR <6648783+RatkoR@users.noreply.github.com>
2026-11-06 13:12:16 +01:00
Martin BallandD13GOOOO cad8ede1e8 Shapes: pictures and text boxes inside a drawing canvas are selected and moved on their own (#2637) (#2670)
Lands #2670.

Co-Authored-By: D13GOOOO <106814760+D13GOOOO@users.noreply.github.com>
2026-11-06 12:44:48 +01:00
Martin BallandRatkoR beca3f3485 Layout: a float anchored on a later page of a long paragraph is drawn there (#4250) (#4267)
Lands #4267.

Co-Authored-By: RatkoR <6648783+RatkoR@users.noreply.github.com>
2026-11-06 12:13:11 +01:00
39 changed files with 2005 additions and 102 deletions
+9 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
1e515422afdda2f317af430c1f9d68a5f83935b4
+2 -2
View File
@@ -90,8 +90,8 @@ pub use settings::{
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
THEME_COLORS, Watermark, Zoom, ZoomFit,
};
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
pub(crate) use text_walk::walk_between;
pub use text_walk::{PARA_WALK_DEPTH, count_chars, count_chars_no_spaces, count_words};
/// A document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+19 -5
View File
@@ -316,20 +316,34 @@ impl InlineObject {
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
/// (x, y, w, h) and the member. Empty for anything else.
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
let InlineObject::Group { children, .. } = self else { return Vec::new() };
self.member_rects(x, y, w, h).into_iter().filter_map(|(i, r)| Some((r, &children.get(i)?.obj))).collect()
}
/// [`InlineObject::group_rects`] with each member's index in `children` instead of the member.
pub fn member_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<(usize, [f32; 4])> {
let InlineObject::Group { children, .. } = self else { return Vec::new() };
let (sx, sy) = self.group_scale(w, h);
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
children
.iter()
.take(MAX_GROUP_CHILDREN)
.filter_map(|c| {
.enumerate()
.filter_map(|(i, c)| {
let (cw, chh, _) = c.obj.frame()?;
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
(!matches!(c.obj, InlineObject::Group { .. })).then_some((i, r))
})
.collect()
}
/// How much a group drawn `w` × `h` scales its members' space (x, y): 1 for a canvas, whose
/// members keep their size.
pub fn group_scale(&self, w: f32, h: f32) -> (f32, f32) {
let InlineObject::Group { ch_w, ch_h, .. } = self else { return (1.0, 1.0) };
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
(sx, sy)
}
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
/// the `w:ffData` it keeps; `None` for anything else.
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
+1 -1
View File
@@ -48,7 +48,7 @@ pub fn count_chars_no_spaces(s: &str) -> usize {
}
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
pub(crate) const PARA_WALK_DEPTH: usize = 16;
pub const PARA_WALK_DEPTH: usize = 16;
/// The note and text box stories an object shows, for `Document::reachable`.
pub(crate) fn notes_and_boxes(o: &InlineObject) -> Vec<(u32, &'static [PartKind])> {
+6 -1
View File
@@ -169,6 +169,9 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
if a == b {
return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false });
}
if canvas::selected_member(s).is_some() {
return Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into()));
}
controls::check_delete(s, &a, &b)?;
let effect = if s.doc.settings.track_changes {
if !track_delete_would_change(s, &a, &b) {
@@ -186,7 +189,9 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
Ok(effect)
}
/// Delete the selection (respecting track changes); returns the collapsed position.
/// Delete the selection (respecting track changes); returns the collapsed position. With a
/// Drawing Canvas member picked (#2637) the selection is the whole canvas, which a command that
/// replaces the selection (Insert › Table, Equation, Symbol, Field…) would take away: refused.
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
delete_selection_effect(s).map(|e| e.at)
}
+320
View File
@@ -5,6 +5,10 @@
//! top-left corner, unscaled). While a canvas is selected, Insert › Pictures, Shapes and Text Box
//! put the new object inside it ([`add_member`]). Fit, Expand and Scale Drawing change its frame;
//! Shape Fill and Shape Outline colour its background and border.
//!
//! A member can be picked on its own (#2637, `select.member`; a click on it does this): Shape
//! Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on that member, and Move or
//! Resize and Nudge move it inside the canvas. The canvas stays the selection in the text.
use serde_json::{Value, json};
use wordcraft_doc::para::{CanvasStyle, Float, GroupChild, InlineObject, MAX_GROUP_CHILDREN};
@@ -14,6 +18,29 @@ use super::objects::object_selection;
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// A member of the selected canvas picked on its own: its index, and the selection it was picked
/// in (it counts only while that selection stands).
#[derive(Clone, Debug, PartialEq)]
pub struct CanvasMember {
sel: Selection,
pub index: usize,
}
/// The canvas member picked on its own: the canvas's position, the member's index and the member.
pub fn selected_member(s: &Session) -> Option<(Pos, usize, &InlineObject)> {
let m = s.member.as_ref().filter(|m| m.sel == s.sel)?;
let (pos, canvas) = selected_canvas(s)?;
let InlineObject::Group { children, .. } = canvas else { return None };
let c = children.get(m.index).filter(|c| pickable(c))?;
Some((pos, m.index, &c.obj))
}
/// Whether a member can be picked on its own: a picture, shape or text box that is drawn (a
/// group inside a canvas isn't, nor a hidden member).
pub fn pickable(c: &GroupChild) -> bool {
c.obj.is_drawing() && !matches!(c.obj, InlineObject::Group { .. }) && !c.obj.frame().is_some_and(|(_, _, f)| f.hidden)
}
/// Largest canvas side and member offset, points (well past any page).
const MAX_SIDE: f32 = 4000.0;
/// Smallest canvas side, points.
@@ -47,6 +74,10 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("canvas.scale", "Scale Drawing", "Shape Format › Drawing Canvas", scale)
.params(r#"{"factor": number (0.1–10)} (scales the frame and every member together)"#)
.when(has_canvas),
CommandSpec::new("select.member", "Select Canvas Member", "Shape Format › Drawing Canvas", select_member)
.params(r#"{"index": n | null} (picks member `index` of the selected drawing canvas on its own: Shape Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on it, and Move or Resize and Nudge move it inside the canvas; null: the canvas itself again)"#)
.when(has_canvas)
.pure(),
CommandSpec::new("canvas.member", "Canvas Member", "Shape Format › Drawing Canvas", member)
.params(r#"{"index": n, "x"?: pt, "y"?: pt, "width"?: pt, "height"?: pt, "delete"?: bool} (moves, resizes or removes member `index` of the selected canvas; x/y from the canvas's top-left; with no change, returns the members)"#)
.when(has_canvas),
@@ -224,9 +255,171 @@ fn member(s: &mut Session, v: &Value) -> CmdResult {
c.obj.set_size(nw.unwrap_or(w0).clamp(1.0, MAX_SIDE), nh.unwrap_or(h0).clamp(1.0, MAX_SIDE));
}
})?;
// The indices after a removed member shift down, so a picked member would name another one.
if delete {
s.member = None;
}
result(&o)
}
/// `select.member`: pick a member of the selected canvas on its own, or (`null`) the canvas again.
fn select_member(s: &mut Session, v: &Value) -> CmdResult {
let Some((_, InlineObject::Group { children, .. })) = selected_canvas(s) else {
return Err(CmdError::Disabled("select a drawing canvas first".into()));
};
let index = match v.get("index") {
None | Some(Value::Null) => None,
Some(_) => {
Some(p::u64(v, "index").and_then(|i| usize::try_from(i).ok()).ok_or_else(|| CmdError::Params("`index` is a member number".into()))?)
}
};
let Some(i) = index else {
s.member = None;
return sel_result(s);
};
let count = children.len();
let c = children.get(i).ok_or_else(|| CmdError::Params(format!("no member {i} (the canvas has {count})")))?;
if !pickable(c) {
return Err(CmdError::Params(format!("member {i} can't be selected on its own")));
}
let (w, h) = c.obj.frame().map(|(w, h, _)| (w, h)).unwrap_or((0.0, 0.0));
let out = json!({"index": i, "x": c.x, "y": c.y, "width": w, "height": h, "object": serde_json::to_value(&c.obj).unwrap_or(Value::Null)});
s.member = Some(CanvasMember { sel: s.sel.clone(), index: i });
Ok(out)
}
/// Delete, Backspace (and their word forms), Cut and Copy with a member picked act on the member
/// alone; the canvas stays selected. Typing and pasting, which would replace the whole canvas,
/// are disabled. `None`: another command, or no member picked.
pub(crate) fn member_command(s: &mut Session, id: &str, _: &Value) -> Option<CmdResult> {
let replaces = (id.starts_with("text.") && id != "text.overtype")
|| matches!(
id,
"edit.paste" | "edit.pasteText" | "edit.pasteMerge" | "edit.pasteSpecial" | "edit.pasteClipboardItem" | "edit.pasteAllClipboard"
);
if !replaces && !matches!(id, "edit.cut" | "edit.copy") {
return None;
}
let (pos, index, _) = selected_member(s)?;
Some(match id {
"edit.copy" => copy_member(s, &pos, index),
"edit.cut" => copy_member(s, &pos, index).and_then(|r| remove_member(s, &pos, index).map(|_| r)),
"text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward" => {
remove_member(s, &pos, index).and_then(|_| sel_result(s))
}
_ => Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into())),
})
}
/// Copy member `index` of the canvas at `pos` alone: pasted, it is a picture or shape of its own.
fn copy_member(s: &mut Session, pos: &Pos, index: usize) -> CmdResult {
let end = Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() };
let mut f = s.doc.copy_range(pos, &end);
let mut copied = false;
for b in &mut f.blocks {
let wordcraft_doc::Block::Para(p) = b else { continue };
for o in &mut p.objects {
if let InlineObject::Group { children, canvas: Some(_), .. } = o
&& let Some(c) = children.get(index)
{
*o = c.obj.clone();
copied = true;
}
}
}
if !copied {
return Err(CmdError::Failed("the member vanished".into()));
}
s.clipboard_text = f.clipboard_text();
s.clip_history.push(&f, &s.clipboard_text);
s.clipboard = Some(f);
Ok(super::edit::copy_result(s))
}
/// Take member `index` out of the canvas at `pos`; the canvas stays selected.
fn remove_member(s: &mut Session, pos: &Pos, index: usize) -> Result<(), CmdError> {
edit_canvas(s, pos, |_, _, children| {
if index < children.len() {
children.remove(index);
}
})?;
s.member = None;
Ok(())
}
/// Move or Resize with a member picked: `x`/`y` (the page point of its top-left, as dragging its
/// frame gives it) and the size become its place and size in the canvas. `None`: no member.
pub(crate) fn member_bounds(s: &mut Session, v: &Value) -> Option<CmdResult> {
let (pos, index, obj) = selected_member(s)?;
let (w0, h0, _) = obj.frame()?;
let min = super::objects::min_size(obj);
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
(Some(x), Some(y)) => Some((x, y)),
(None, None) => None,
_ => return Some(Err(CmdError::Params("`x` and `y` go together".into()))),
};
// `x`/`y` are on the canvas's page: a drop on another page would put the member outside it.
if to.is_some()
&& let Some(page) = p::u64(v, "page").and_then(|n| usize::try_from(n).ok())
&& s.layout().object(&pos, s.page_hint).is_some_and(|o| o.page != page)
{
return Some(Err(CmdError::Disabled("a shape can't leave its drawing canvas".into())));
}
let (nw, nh) = (p::f32(v, "width"), p::f32(v, "height"));
Some(edit_member(s, &pos, index, |frame, (sx, sy), c| {
if let Some((x, y)) = to {
c.x = ((x - frame.x) / sx).clamp(-MAX_SIDE, MAX_SIDE);
c.y = ((y - frame.y) / sy).clamp(-MAX_SIDE, MAX_SIDE);
}
let w = nw.map_or(w0, |w| w / sx).clamp(min, MAX_SIDE);
let h = nh.map_or(h0, |h| h / sy).clamp(min, MAX_SIDE);
if (w, h) != (w0, h0) {
c.obj.set_size(w, h);
}
}))
}
/// Nudge with a member picked: it moves inside the canvas. `None`: no member.
pub(crate) fn member_nudge(s: &mut Session, dx: f32, dy: f32) -> Option<CmdResult> {
let (pos, index, _) = selected_member(s)?;
Some(edit_member(s, &pos, index, |_, (sx, sy), c| {
c.x = (c.x + dx / sx).clamp(-MAX_SIDE, MAX_SIDE);
c.y = (c.y + dy / sy).clamp(-MAX_SIDE, MAX_SIDE);
}))
}
/// Change member `index` of the canvas at `pos`: `f` gets the canvas's frame on the page, the
/// scale of its members' space and the member.
fn edit_member(s: &mut Session, pos: &Pos, index: usize, f: impl FnOnce(wordcraft_geom::Rect, (f32, f32), &mut GroupChild)) -> CmdResult {
let frame = s.layout().object(pos, s.page_hint).ok_or_else(|| CmdError::Failed("the drawing canvas isn't laid out".into()))?.rect;
let canvas =
s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("the drawing canvas vanished".into()))?;
let scale = canvas.group_scale(frame.w, frame.h);
let mut f = Some(f);
let canvas = edit_canvas(s, pos, |_, _, children| {
if let (Some(c), Some(f)) = (children.get_mut(index), f.take()) {
f(frame, scale, c);
}
})?;
let InlineObject::Group { children, .. } = &canvas else { return result(&canvas) };
let member = children.get(index).map(|c| serde_json::to_value(&c.obj).unwrap_or(Value::Null)).unwrap_or(Value::Null);
Ok(json!({"object": member, "pos": super::pos_json(pos), "member": index}))
}
/// Change the picked member's own object (Shape Format, Size, Rotate, Alt Text) when `pos` is its
/// canvas. `None`: no member picked there.
pub(crate) fn edit_member_object(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Option<Result<InlineObject, CmdError>> {
let (at, index, _) = selected_member(s).filter(|(at, ..)| at == pos)?;
let mut out = None;
let r = edit_canvas(s, &at, |_, _, children| {
if let Some(c) = children.get_mut(index) {
f(&mut c.obj);
out = Some(c.obj.clone());
}
});
Some(r.and_then(|_| out.ok_or_else(|| CmdError::Failed("the member vanished".into()))))
}
/// Shape Fill and Shape Outline on a selected canvas colour its background and border.
pub fn style_mut(o: &mut InlineObject) -> Option<&mut CanvasStyle> {
match o {
@@ -327,4 +520,131 @@ mod tests {
fn canvas_in_text(s: &Session) -> InlineObject {
s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()).cloned().unwrap()
}
fn member_fill(o: &InlineObject, i: usize) -> Option<wordcraft_doc::fill::Fill> {
let InlineObject::Group { children, .. } = o else { panic!("{o:?}") };
match &children[i].obj {
InlineObject::Shape { fill, .. } => fill.clone(),
o => panic!("{o:?}"),
}
}
/// #2637: a shape inside a drawing canvas can be picked on its own, as in Word. Shape Format,
/// Move or Resize, Nudge, Delete and Copy then act on it rather than on the whole canvas,
/// which stays the selection in the text; the commands that place an object in the text
/// (wrapping, position) don't apply to it.
#[test]
fn a_canvas_member_is_selected_and_changed_on_its_own() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
s.run("canvas.member", &json!({"index": 0, "x": 40, "y": 30})).unwrap();
s.run("canvas.member", &json!({"index": 1, "x": 200, "y": 100})).unwrap();
let canvas_sel = s.sel.clone();
assert!(s.run("select.member", &json!({"index": 5})).is_err(), "no such member");
s.run("select.member", &json!({"index": 1})).unwrap();
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
assert_eq!(s.sel, canvas_sel, "the canvas stays the selection in the text");
// Shape Fill colours the member, not the canvas or the other member.
let other = member_fill(&canvas(&s), 0);
s.run("shape.fill", &json!({"color": "FF0000"})).unwrap();
let c = canvas(&s);
assert_eq!(member_fill(&c, 1), wordcraft_doc::props::Rgb::parse("FF0000").map(wordcraft_doc::fill::Fill::solid));
assert_eq!(member_fill(&c, 0), other);
assert_eq!(c.canvas_style().and_then(|st| st.fill.clone()), None);
assert!(selected_member(&s).is_some(), "still picked after a change");
// Dragging its frame: the page point becomes its place in the canvas.
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap().rect;
s.run("arrange.bounds", &json!({"page": 0, "x": frame.x + 10.0, "y": frame.y + 20.0, "width": 60, "height": 30})).unwrap();
assert_eq!(members(&canvas(&s))[1], (10.0, 20.0, 60.0, 30.0));
assert_eq!(canvas(&s).frame().map(|(w, h, _)| (w, h)), Some((400.0, 300.0)), "the canvas keeps its frame");
s.run("arrange.nudge", &json!({"dx": 6, "dy": -6})).unwrap();
assert_eq!(members(&canvas(&s))[1], (16.0, 14.0, 60.0, 30.0));
assert!(s.run("arrange.wrap", &json!({"wrap": "square"})).is_err(), "the canvas places its members");
// Copy puts the member alone on the clipboard; Delete takes it out of the canvas.
s.run("edit.copy", &json!({})).unwrap();
let clip = s.clipboard.clone().unwrap();
let copied: Vec<&InlineObject> = clip.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect();
assert!(matches!(copied.as_slice(), [InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. }]), "{copied:?}");
s.run("text.delete", &json!({})).unwrap();
assert_eq!(members(&canvas(&s)), [(40.0, 30.0, 100.0, 40.0)]);
assert!(selected_member(&s).is_none());
assert_eq!(s.sel, canvas_sel, "the canvas is still selected");
// Undo puts the member back.
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(members(&canvas_in_text(&s)).len(), 2);
// Picking the canvas again (or anything else) ends the member's selection.
s.run("select.objects", &json!({"index": 0})).unwrap();
s.run("select.member", &json!({"index": 0})).unwrap();
s.run("select.member", &json!({"index": null})).unwrap();
assert!(selected_member(&s).is_none());
s.run("select.member", &json!({"index": 0})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
assert!(selected_member(&s).is_none());
// Pasted outside the canvas, the copied member is a shape of its own in the text.
s.run("edit.paste", &json!({})).unwrap();
let para = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
assert!(matches!(para.objects.last(), Some(InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. })), "{:?}", para.objects);
}
#[test]
fn typing_or_removing_a_member_never_hits_another_shape_or_the_canvas() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
s.run("select.member", &json!({"index": 1})).unwrap();
// Typing or pasting would replace the whole canvas: refused, and the member stays picked.
for (id, v) in [
("text.insert", json!({"text": "x"})),
("text.newParagraph", json!({})),
("edit.paste", json!({"text": "x"})),
("edit.pasteText", json!({"text": "x"})),
] {
assert!(matches!(s.run(id, &v), Err(CmdError::Disabled(_))), "{id}");
assert_eq!(members(&canvas(&s)).len(), 2, "{id}");
}
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
// Ctrl+Backspace removes the member alone, like Backspace.
s.run("text.deleteWordBack", &json!({})).unwrap();
let sizes: Vec<(f32, f32)> = members(&canvas(&s)).iter().map(|m| (m.2, m.3)).collect();
assert_eq!(sizes, [(100.0, 40.0)], "the ellipse went, the rectangle stays");
assert!(selected_member(&s).is_none());
// Removing an earlier member through `canvas.member` drops the pick: its index would now
// name the next member.
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 20, "height": 20})).unwrap();
s.run("select.member", &json!({"index": 1})).unwrap();
s.run("canvas.member", &json!({"index": 0, "delete": true})).unwrap();
assert!(selected_member(&s).is_none(), "the pick doesn't slide onto the member that moved up");
// Commands that replace the selection (the whole canvas) are refused too.
s.run("select.member", &json!({"index": 0})).unwrap();
for (id, v) in [
("insert.table", json!({"rows": 1, "cols": 1})),
("table.quick", json!({})),
("insert.equation", json!({})),
("insert.symbol", json!({"char": "§"})),
("insert.field", json!({"instr": "AUTHOR"})),
("insert.pageBreak", json!({})),
] {
assert!(s.run(id, &v).is_err(), "{id}");
assert_eq!(members(&canvas(&s)).len(), 2, "{id}: the canvas and its members stay");
}
// Moving the member: on the canvas's page it moves; a drop on another page is refused.
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap();
let before = members(&canvas(&s));
let other = frame.page + 1;
assert!(matches!(s.run("arrange.bounds", &json!({"page": other, "x": 100, "y": 100})), Err(CmdError::Disabled(_))));
assert_eq!(members(&canvas(&s)), before);
s.run("arrange.bounds", &json!({"page": frame.page, "x": frame.rect.x + 5.0, "y": frame.rect.y + 5.0})).unwrap();
assert_ne!(members(&canvas(&s)), before);
}
}
+1 -1
View File
@@ -361,7 +361,7 @@ impl ClipHistory {
self.items.is_empty()
}
fn push(&mut self, f: &Fragment, text: &str) {
pub(crate) fn push(&mut self, f: &Fragment, text: &str) {
// Text plus per-paragraph overhead; pictures are references into the document.
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
+7 -3
View File
@@ -6,6 +6,9 @@ pub(super) const MAX_OFFSET: f32 = 4000.0;
/// Resize and/or move the selected object (dragging its frame or handles).
pub(super) fn bounds(s: &mut Session, v: &Value) -> CmdResult {
if let Some(r) = crate::cmd::canvas::member_bounds(s, v) {
return r;
}
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)),
@@ -58,7 +61,7 @@ fn landing(s: &Session, pos: &Pos, to: &Pos) -> Result<Pos, CmdError> {
/// 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.
/// where its 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)?;
@@ -161,8 +164,9 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
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 {
// Move its character to the anchor, unless it's already in that paragraph and drawn on this
// page (one anchored on a later page of it is drawn there, #4250).
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page && layout.object(pos, page).is_some_and(|o| o.page == 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()))?;
+24 -2
View File
@@ -12,6 +12,10 @@ pub(super) fn has_object(s: &Session) -> Option<&'static str> {
}
pub(super) fn has_floating(s: &Session) -> Option<&'static str> {
// A canvas member moves inside its canvas.
if crate::cmd::canvas::selected_member(s).is_some() {
return None;
}
match selected(s) {
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
Some((_, o)) if o.is_floating() => None,
@@ -51,6 +55,15 @@ pub(super) fn has_movable(s: &Session) -> Option<&'static str> {
}
}
/// `has_movable` for what places an object in the text (wrapping, position, alignment, Move with
/// Text): not for a member picked inside a drawing canvas, which the canvas places.
pub(super) fn has_placeable(s: &Session) -> Option<&'static str> {
if crate::cmd::canvas::selected_member(s).is_some() {
return Some("a shape inside a drawing canvas is placed by the canvas");
}
has_movable(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.
pub(super) const INK_GROUP: &str = "a group holding Word ink can't be ungrouped yet";
@@ -99,7 +112,9 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
o.is_drawing().then_some((a, o))
}
/// The first picture/shape in the selection, or just before a collapsed caret.
/// The first picture/shape in the selection, or just before a collapsed caret. With a canvas
/// member picked ([`crate::cmd::canvas::selected_member`]), that member, at its canvas's position:
/// the commands that change it go through [`edit_obj`], which changes the member.
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
selected_all(s, 1).into_iter().next()
}
@@ -107,6 +122,9 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
/// 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)> {
if let Some((pos, _, member)) = crate::cmd::canvas::selected_member(s) {
return vec![(pos, member.clone())];
}
let mut out = Vec::new();
let (a, b) = s.sel.ordered();
let max = if a == b { 1 } else { max };
@@ -175,8 +193,12 @@ pub(super) fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdRes
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
}
/// Change the object at `pos`; returns it afterwards.
/// Change the object at `pos`; returns it afterwards. With a member of the canvas at `pos`
/// picked, the member changes.
pub(crate) fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> {
if let Some(r) = crate::cmd::canvas::edit_member_object(s, pos, &f) {
return r;
}
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);
+13 -10
View File
@@ -2,8 +2,8 @@ use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
use super::group::{add_object, group, ungroup};
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
use super::select::{
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_shape, has_shape_or_canvas,
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_placeable, has_shape,
has_shape_or_canvas, has_sizable, obj_size, objects_list, selected, with_float, with_obj,
};
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
@@ -193,17 +193,17 @@ pub fn specs() -> Vec<CommandSpec> {
with_float(s, |f| f.wrap = wrap)
})
.params(r#"{"wrap": "inline|square|tight|through|topAndBottom|behindText|inFrontOfText"}"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.wrapText", "Wrap Text Sides", "Layout › Arrange", |s, v| {
let side = p::str(v, "side").unwrap_or("bothSides");
let side = WrapText::from_ooxml(side).ok_or_else(|| CmdError::Params(format!("unknown side {side:?}")))?;
with_float(s, |f| f.wrap_text = side)
})
.params(r#"{"side": "bothSides|left|right|largest"} (the sides of the selected Square, Tight or Through object text flows on; largest: the wider side, line by line)"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.moveWithText", "Move with Text", "Layout › Arrange › Wrap Text", |s, v| move_with_text(s, p::bool(v, "value").unwrap_or(true)))
.params(r#"{"value": bool} (true: Move with Text, the floating object's vertical position follows its paragraph; false: Fix Position on Page. It stays where it is)"#)
.when(has_floating),
.when(|s| has_placeable(s).or_else(|| has_floating(s))),
CommandSpec::new("arrange.position", "Position", "Layout › Arrange", |s, v| {
let preset = p::str(v, "preset").unwrap_or("middleCenter").to_string();
let (x, y) = (p::f32(v, "x"), p::f32(v, "y"));
@@ -224,9 +224,9 @@ pub fn specs() -> Vec<CommandSpec> {
})
})
.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),
.when(has_placeable),
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_placeable),
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_placeable),
CommandSpec::new("arrange.align", "Align", "Layout › Arrange", |s, v| {
let h = p::str(v, "value").unwrap_or("center").to_string();
let tw = crate::cmd::page::sect(s).text_width();
@@ -245,17 +245,20 @@ pub fn specs() -> Vec<CommandSpec> {
})
})
.params(r#"{"value": "left|center|right"}"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n, "to"?: Pos} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object by x/y floats it. to: an In Line with Text object moves to that text position and stays inline, as a drag does)"#)
.when(has_movable),
CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to)
.params(r#"{"x": pt, "y": pt, "page"?: n} | {"to": Pos} (Ctrl+drag: a copy of the selected object lands with its top-left there, or an In Line with Text one at the text position `to`, and is selected; the original and the clipboard stay as they were)"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
let (dx, dy) = (d("dx"), d("dy"));
if let Some(r) = crate::cmd::canvas::member_nudge(s, dx, dy) {
return r;
}
unalign(s)?;
with_float(s, |f| {
f.x = (f.x + dx).clamp(-MAX_OFFSET, MAX_OFFSET);
+63
View File
@@ -14,6 +14,69 @@ fn object_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
s.layout().find_object(0, |o| o.text_box.is_none()).unwrap()
}
/// #4250: a shape dragged into the middle of a page that only continues a long paragraph
/// stays there, as in Word: anchored at the line beside it, it's drawn on that page, not
/// thrown back to the page the paragraph starts on. One undo step puts it back.
#[test]
fn a_shape_dropped_on_a_page_a_paragraph_continues_onto_stays_there() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
assert!(s.layout().pages.len() > 2);
let before = (object_area(&mut s), s.undo_depth());
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275})).unwrap();
let o = object_area(&mut s);
assert_eq!(o.page, 1, "on the page it was dropped on: {o:?}");
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 275.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
assert_eq!(s.undo_depth(), before.1 + 1);
s.run("edit.undo", &json!({})).unwrap();
let back = object_area(&mut s);
assert_eq!((back.page, back.rect), (before.0.page, before.0.rect));
}
/// #4250: and dragged back onto the page the paragraph starts on, it stays there: its anchor
/// goes back to the paragraph's start rather than staying on the later page's line.
#[test]
fn a_shape_dragged_back_to_the_page_its_paragraph_starts_on_stays_there() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275, "width": 100, "height": 50})).unwrap();
let before = (object_area(&mut s), s.undo_depth());
assert!(before.0.page == 1 && before.0.off > 0, "anchored on page 2's line: {:?}", before.0);
s.run("arrange.bounds", &json!({"page": 0, "x": 120, "y": 300})).unwrap();
let o = object_area(&mut s);
assert_eq!((o.page, o.off), (0, 0), "on the page it was dropped on, anchored at the paragraph's start: {o:?}");
assert!((o.rect.x - 120.0).abs() < 0.5 && (o.rect.y - 300.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
assert_eq!(s.undo_depth(), before.1 + 1);
}
/// #4262: dropped beside the first line after a page break inside a paragraph, a shape stays
/// on the page it was dropped on, anchored in that line.
#[test]
fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
let mut s = Session::new(wordcraft_doc::Document::new());
let text = "The quick brown fox jumps over the lazy dog. ";
s.run("text.insert", &json!({"text": text.repeat(20)})).unwrap();
s.run("insert.pageBreak", &json!({})).unwrap();
s.run("text.insert", &json!({"text": text.repeat(40)})).unwrap();
assert_eq!(s.doc.body.len(), 1, "one paragraph");
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 80})).unwrap();
let o = object_area(&mut s);
let after_break = text.len() * 20 + wordcraft_doc::para::PAGE_BREAK.len_utf8();
assert_eq!((o.page, o.off), (1, after_break), "on the page it was dropped on, anchored right after the break: {o:?}");
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
}
/// 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) {
+22 -8
View File
@@ -652,19 +652,33 @@ pub(crate) fn typing_pos(s: &Session, p: &Pos) -> Pos {
if para.objects.is_empty() {
return p.clone();
}
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
let Some(before) = para.text.get(..p.off) else { return p.clone() };
let Some(after) = para.text.get(p.off..) else { return p.clone() };
if !before.ends_with(OBJ) && !after.starts_with(OBJ) {
return p.clone();
}
let editable: HashSet<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
// Count the preceding objects once. Each adjacent marker can then use the matching
// object directly, forwards for starts and backwards for ends, even with short storage.
let index = before.chars().filter(|c| *c == OBJ).count();
let w = OBJ.len_utf8();
let mut off = p.off;
if is_start(off) {
while is_start(off) {
off += w;
for (ch, object) in after.chars().zip(para.objects.iter().skip(index)) {
if ch != OBJ || !matches!(object, InlineObject::PermStart { id, .. } if editable.contains(id)) {
break;
}
off += w;
}
if off > p.off {
return Pos { off, ..p.clone() };
}
while off >= w && is_end(off - w) {
off -= w;
if let Some(objects) = para.objects.get(..index) {
for (ch, object) in before.chars().rev().zip(objects.iter().rev()) {
if ch != OBJ || !matches!(object, InlineObject::PermEnd { id } if editable.contains(id)) {
break;
}
off -= w;
}
}
Pos { off, ..p.clone() }
}
+156
View File
@@ -922,3 +922,159 @@ fn legacy_password_is_verified_and_unknown_forms_refused() {
assert!(e.to_string().contains(super::UNSUPPORTED_PASSWORD));
assert!(super::enforced(&s.doc));
}
/// Consecutive permission boundaries must not recount the paragraph prefix or search all
/// editable IDs once per marker. Both ends are used by typing and Enter.
#[test]
fn typing_boundary_many_permission_markers_is_not_quadratic() {
use wordcraft_doc::para::Run;
let cpu = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let measure = |n: usize, at_end: bool| {
let mut p = wordcraft_doc::Paragraph::new();
p.text.push_str("λ prefix ");
let start = p.text.len();
for i in 0..n {
p.text.push(OBJ);
p.objects.push(InlineObject::PermStart { id: i as u32, group: "everyone".into(), editor: String::new() });
}
p.text.push_str("body");
let end = p.text.len();
for i in 0..n {
p.text.push(OBJ);
p.objects.push(InlineObject::PermEnd { id: i as u32 });
}
let after = p.text.len();
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
let mut doc = Document::new();
doc.body = vec![Block::Para(p).into()];
doc.settings.protection = Some("readOnly".into());
let s = Session::new(doc);
let at = Pos::body(0, if at_end { after } else { start });
let expected = Pos::body(0, if at_end { end } else { start + n * OBJ.len_utf8() });
let before = cpu();
for _ in 0..3 {
assert_eq!(typing_pos(std::hint::black_box(&s), &at), expected);
}
match (before, cpu()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
};
for at_end in [false, true] {
let (small, large) = (measure(2_000, at_end), measure(16_000, at_end));
let (Some(a), Some(b)) = (small, large) else {
eprintln!("Typing boundary: CPU counter unavailable; semantic checks passed, timing assertion skipped");
continue;
};
eprintln!("Typing boundary end={at_end}: small={a:.9}s large={b:.9}s ratio={:.2}", b / a);
assert!(b < a * 24.0 + 0.005, "8x permission markers must not take quadratic boundary work: {a:.9}s -> {b:.9}s");
}
}
#[test]
fn typing_boundary_keeps_author_precedence_and_malformed_pairing() {
use wordcraft_doc::para::Run;
// Frozen boundary semantics from before the one-pass lookup. This oracle deliberately
// uses object_at independently of the new traversal, including malformed storage.
let original = |s: &Session, p: &Pos| {
if !matches!(s.doc.settings.protection.as_deref(), Some("readOnly" | "comments")) {
return p.clone();
}
let Some(para) = s.doc.para_at(p) else { return p.clone() };
if para.objects.is_empty() {
return p.clone();
}
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
let start = |off| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
let end = |off| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
let mut off = p.off;
if start(off) {
while start(off) {
off += OBJ.len_utf8();
}
} else {
while off >= OBJ.len_utf8() && end(off - OBJ.len_utf8()) {
off -= OBJ.len_utf8();
}
}
Pos { off, ..p.clone() }
};
let objects = [
InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() },
InlineObject::PermStart { id: 2, group: String::new(), editor: " ALICE ".into() },
InlineObject::PermStart { id: 3, group: String::new(), editor: "alice".into() },
InlineObject::PermEnd { id: 1 },
InlineObject::PermEnd { id: 3 },
InlineObject::BookmarkStart { name: "Original".into() },
InlineObject::PermEnd { id: 7 },
InlineObject::PermStart { id: 7, group: String::new(), editor: "Bob".into() },
];
for text in
[format!("λ{}x{}終", OBJ.to_string().repeat(4), OBJ.to_string().repeat(4)), OBJ.to_string().repeat(9), format!("λ{OBJ}{OBJ}x{OBJ}{OBJ}{OBJ}")]
{
for count in 0..=objects.len() {
for mode in [None, Some("readOnly"), Some("comments"), Some("forms"), Some("trackedChanges")] {
let mut p = wordcraft_doc::Paragraph::new();
p.text = text.clone();
p.objects = objects[..count].to_vec();
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
let mut doc = Document::new();
doc.body = vec![Block::Para(p).into()];
doc.settings.protection = mode.map(str::to_string);
let mut s = Session::new(doc);
s.author = " Alice ".into();
for off in 0..=text.len() + 1 {
let at = Pos::body(0, off);
assert_eq!(typing_pos(&s, &at), original(&s, &at), "mode={mode:?} count={count} off={off} text={text:?}");
}
}
}
}
let mut s = Session::new(Document::from_text("outside"));
s.doc.settings.protection = Some("readOnly".into());
assert_eq!(typing_pos(&s, &Pos::body(99, 0)), Pos::body(99, 0));
}
#[test]
fn typing_boundary_keeps_duplicate_ids_across_stories_and_start_precedence() {
use wordcraft_doc::para::Run;
let paragraph = |text: String, objects| wordcraft_doc::Paragraph {
runs: vec![Run { len: text.len(), props: CharProps::default() }],
text,
objects,
..Default::default()
};
let mut doc = Document::new();
doc.body = vec![
Block::Para(paragraph(
format!("{OBJ}x{OBJ}"),
vec![InlineObject::PermStart { id: 1, group: String::new(), editor: "Bob".into() }, InlineObject::PermEnd { id: 1 }],
))
.into(),
];
doc.add_part(
wordcraft_doc::PartKind::Header,
vec![
Block::Para(paragraph(
format!("{OBJ}y{OBJ}"),
vec![InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }, InlineObject::PermEnd { id: 1 }],
))
.into(),
],
);
doc.settings.protection = Some("comments".into());
let mut s = Session::new(doc);
s.author = "Alice".into();
// Preserve the existing document-wide ID membership, even for a duplicate in another story.
assert_eq!(typing_pos(&s, &Pos::body(0, 0)), Pos::body(0, OBJ.len_utf8()));
assert_eq!(typing_pos(&s, &Pos::body(0, 7)), Pos::body(0, 4));
s.doc.body = vec![
Block::Para(paragraph(
format!("{OBJ}{OBJ}x"),
vec![InlineObject::PermEnd { id: 1 }, InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }],
))
.into(),
];
// A start to the right wins over an end to the left.
assert_eq!(typing_pos(&s, &Pos::body(0, 3)), Pos::body(0, 6));
}
+66 -3
View File
@@ -1,4 +1,5 @@
use super::{Arc, CmdError, CmdResult, Document, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p, sel_result};
use wordcraft_doc::CharProps;
/// Options › Language › Set as default: only the saved default proofing language (what future
/// blank documents are written in), as Word's Set as Preferred. A setting, like `file.options`: the
@@ -14,8 +15,15 @@ pub(super) fn preferred_language(s: &mut Session, v: &Value) -> CmdResult {
/// Set Proofing Language: the selection's language and "do not check spelling or grammar",
/// or (`default`) the document's default and the saved language for future blank documents;
/// `detect` is the per-user detection preference.
/// Nothing at all sets US English, as before.
/// Nothing at all sets US English, as before. `scope: "document"` sets it for every story.
pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
// Checked first, so a bad scope changes nothing (not even the detect preference).
let whole_document = match v.get("scope") {
None | Some(Value::Null) => false,
Some(Value::String(t)) if t == "selection" => false,
Some(Value::String(t)) if t == "document" => true,
Some(other) => return Err(CmdError::Params(format!("`scope` must be \"selection\" or \"document\", not {other}"))),
};
let lang = match p::str(v, "lang") {
Some(t) => Some(
wordcraft_proof::lang::normalize_tag(t)
@@ -45,20 +53,75 @@ pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
if lang.is_none() && no_proof.is_none() {
return Ok(json!({"detect": s.prefs.detect_language}));
}
let result = crate::cmd::format::apply(s, &|c| {
let change = |c: &mut CharProps| {
if let Some(l) = &lang {
c.lang = Some(l.clone());
}
if let Some(np) = no_proof {
c.no_proof = Some(np);
}
})?;
};
let result = if whole_document {
set_document_props(s, &change)?;
json!({"scope": "document"})
} else {
crate::cmd::format::apply(s, &change)?
};
if p::bool(v, "default") == Some(true) {
s.prefs.default_language = lang;
}
Ok(result)
}
/// Apply `f` to the character formatting of every story of the document (Language dialog, "Apply
/// to the whole document"): text and paragraph marks of the body, headers, footers, notes,
/// comments, text boxes (all `Document::parts`), table cells, and the glossary's building blocks.
/// The selection is untouched; formatting waiting for typing follows.
fn set_document_props(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> Result<(), CmdError> {
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
for story in stories {
for path in s.doc.para_paths(story) {
let para = s.doc.para_mut(story, &path)?;
let len = para.len();
para.format(0, len, f)?;
f(&mut para.mark);
para.touch();
}
}
for block in &mut s.doc.glossary {
format_blocks(&mut block.blocks, f, 0);
}
if let Some(pend) = s.pending.as_mut() {
f(pend);
}
Ok(())
}
/// [`set_document_props`] for a block list outside any story (depth-limited like `para_paths`).
fn format_blocks(blocks: &mut wordcraft_doc::Blocks, f: &dyn Fn(&mut CharProps), depth: usize) {
if depth > wordcraft_doc::PARA_WALK_DEPTH {
return;
}
for blk in blocks.iter_mut() {
match Arc::make_mut(blk) {
wordcraft_doc::Block::Para(p) => {
let len = p.len();
// A range inside the paragraph can't fail; the mark is formatted regardless.
let _ = p.format(0, len, f);
f(&mut p.mark);
p.touch();
}
wordcraft_doc::Block::Table(t) => {
for row in &mut t.rows {
for cell in &mut row.cells {
format_blocks(&mut cell.blocks, f, depth + 1);
}
}
}
}
}
}
pub(super) fn macros(s: &mut Session, v: &Value) -> CmdResult {
if let Some(d) = v.get("define") {
let name = d.get("name").and_then(Value::as_str).unwrap_or("Macro").to_string();
+1 -1
View File
@@ -340,7 +340,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#),
CommandSpec::new("review.language", "Language", "Review › Language", language).params(
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents), "scope"?: "selection" (default) | "document" (every run and paragraph mark of every story: body, headers, footers, notes, comments, text boxes, tables, glossary; one undo step, selection unchanged)}"#,
),
CommandSpec::new("review.language.preferred", "Set as Default Language", "File › Options › Language", preferred_language)
.params(r#"{"lang": "en-US|en-GB|pl-PL|nl-NL|…" (BCP 47)} — only the saved default proofing language, what future blank documents are written in, as Options › Language › Set as default does; the open document, the selection and the undo history don't change. Returns {"preferred": tag}."#)
+101
View File
@@ -290,3 +290,104 @@ fn inspect_removals_work_when_the_document_does_not_track_changes() {
assert!(!text.contains("Hidden"), "the hidden text is still there: {text}");
assert!(text.contains("Visible text."), "{text}");
}
/// #3518: every run and paragraph mark of a story, as (story, path, lang, noProof) rows.
fn proofing_rows(s: &Session) -> Vec<(StoryRef, Option<String>, Option<bool>)> {
let stories = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k)));
let mut out = Vec::new();
for st in stories {
for path in s.doc.para_paths(st) {
let Some(p) = s.doc.para(st, &path) else { continue };
out.push((st, p.mark.lang.clone(), p.mark.no_proof));
out.extend(p.runs.iter().map(|r| (st, r.props.lang.clone(), r.props.no_proof)));
}
}
out
}
/// A document with its text in German in the body, a table cell, a header, a footnote, a comment
/// and a text box, as a Word DOCX keeps a `w:lang` on each run, plus a glossary entry.
fn multi_story_doc() -> Session {
use wordcraft_doc::{Block, BuildingBlock, CharProps, Paragraph, PartKind, Table};
let de = || CharProps { lang: Some("de-DE".into()), ..Default::default() };
let blk = |t: &str| Arc::new(Block::Para(Paragraph::with_text(t, de())));
let mut d = Document::from_text("Guten Tag");
d.body = vec![blk("Guten Tag")];
let mut t = Table::new(1, 2, 200.0);
if let Some(c) = t.rows.first_mut().and_then(|r| r.cells.first_mut()) {
c.blocks = vec![blk("Zelle")];
}
d.body.push(Arc::new(Block::Table(t)));
d.body.push(blk("Ende"));
for kind in [PartKind::Header, PartKind::Footnote, PartKind::Comment, PartKind::TextBox] {
d.add_part(kind, vec![blk("Teil")]);
}
d.glossary.push(BuildingBlock { name: "g".into(), blocks: vec![blk("Baustein")], ..Default::default() });
Session::new(d)
}
#[test]
fn language_scope_document_sets_every_story() {
let mut s = multi_story_doc();
s.run("select.text", &json!({"text": "Guten"})).unwrap();
let (sel, before) = (s.sel.clone(), s.doc.clone());
let rows = proofing_rows(&s);
assert!(rows.len() >= 10 && rows.iter().any(|r| r.1.as_deref() == Some("de-DE")));
s.run("review.language", &json!({"lang": "fr-FR", "scope": "document"})).unwrap();
// Runs that had their own language, the paragraph marks and the empty cell: all French.
let rows = proofing_rows(&s);
assert!(rows.iter().all(|r| r.1.as_deref() == Some("fr-FR")), "{rows:?}");
for kind_story in s.doc.parts.keys() {
assert!(rows.iter().any(|r| r.0 == StoryRef::Part(*kind_story)));
}
assert!(s.doc.plain_text(StoryRef::Body).contains("Zelle"));
let g = &s.doc.glossary[0].blocks;
assert_eq!(g[0].as_para().map(|p| p.runs[0].props.lang.clone()), Some(Some("fr-FR".into())));
assert_eq!(g[0].as_para().map(|p| p.mark.lang.clone()), Some(Some("fr-FR".into())));
// The selection stays; the document is modified.
assert_eq!(s.sel, sel);
assert!(s.doc != before);
// One undo restores everything.
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc == before);
assert_eq!(s.sel, sel);
}
#[test]
fn language_scope_document_no_proof_and_pending() {
let mut s = multi_story_doc();
s.run("review.language", &json!({"noProof": true, "scope": "document"})).unwrap();
assert!(proofing_rows(&s).iter().all(|r| r.2 == Some(true)));
// The language itself wasn't asked for and stays.
assert!(proofing_rows(&s).iter().any(|r| r.1.as_deref() == Some("de-DE")));
// Formatting waiting for typing follows.
s.run("format.bold", &json!({})).unwrap();
s.run("review.language", &json!({"lang": "pl-PL", "scope": "document"})).unwrap();
assert_eq!(s.pending.as_ref().and_then(|p| p.lang.as_deref()), Some("pl-PL"));
}
#[test]
fn language_default_scope_is_selection_only() {
let mut s = multi_story_doc();
s.run("select.text", &json!({"text": "Guten"})).unwrap();
s.run("review.language", &json!({"lang": "fr-FR"})).unwrap();
let rows = proofing_rows(&s);
assert!(rows.iter().any(|r| r.1.as_deref() == Some("fr-FR")));
assert!(rows.iter().filter(|r| r.1.as_deref() == Some("fr-FR")).count() < rows.len() / 2);
let mut s = multi_story_doc();
s.run("select.text", &json!({"text": "Guten"})).unwrap();
s.run("review.language", &json!({"lang": "fr-FR", "scope": "selection"})).unwrap();
assert_eq!(proofing_rows(&s), rows);
}
#[test]
fn language_bad_scope_is_a_params_error() {
let mut s = multi_story_doc();
let before = s.doc.clone();
for bad in [json!("everything"), json!(1), json!(true), json!(["document"])] {
let e = s.run("review.language", &json!({"lang": "fr-FR", "detect": false, "scope": bad})).unwrap_err();
assert!(matches!(e, CmdError::Params(_)), "{e:?}");
}
assert!(s.doc == before);
assert!(s.prefs.detect_language);
}
+4
View File
@@ -219,6 +219,10 @@ pub struct Session {
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
/// for Group. Cleared by any edit or selection change other than adding to it.
pub also_selected: Vec<Pos>,
/// The member of the selected Drawing Canvas picked on its own (#2637), which Shape Format,
/// Delete, Copy and the frame act on; see [`crate::cmd::canvas::selected_member`]. Cleared by
/// any selection change, caret movement or undo.
pub member: Option<crate::cmd::canvas::CanvasMember>,
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
pub column: Option<ColumnBlock>,
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
+4
View File
@@ -276,6 +276,8 @@ impl Session {
ime_state_exhausted: _,
pending_ime_application_change: _,
ime_origin: _,
// A picked Drawing Canvas member is cleared by a switch, not kept per document.
member: _,
others: _,
order: _,
untitled_count: _,
@@ -376,6 +378,8 @@ impl Session {
swap(&mut self.widened, widened);
swap(&mut self.edit_marks, edit_marks);
swap(&mut self.go_back_step, go_back_step);
// A picked Drawing Canvas member (#2637) belongs to the document left behind.
self.member = None;
}
/// Make document `id` active without anything else a switch by the user does.
+1
View File
@@ -150,6 +150,7 @@ impl Session {
math_latex: false,
math_normal_text: false,
also_selected,
member: None,
prefs: Prefs::default(),
system_region: Default::default(),
keymap: crate::KeyMap::default(),
+13 -1
View File
@@ -185,7 +185,10 @@ impl Session {
let mutates = spec.mutates;
let ime_caret_before = self.current_ime_caret_state();
let rev_before = self.rev;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match crate::cmd::canvas::member_command(self, id, params) {
Some(r) => r,
None => crate::cmd::column::dispatch(self, id, mutates, run, params),
}));
let mut result = match result {
Ok(r) => r,
Err(p) => {
@@ -391,6 +394,15 @@ impl Session {
if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != *sel_before) {
self.also_selected.clear();
}
// A canvas member stays picked while its canvas does and nothing else is selected.
if id != "select.member"
&& (self.sel != *sel_before
|| id.starts_with("select.")
|| id.starts_with("caret.")
|| matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo"))
{
self.member = None;
}
self.note_typing(id, before_doc.as_ref().map(|b| &b.0), sel_before);
}
+3
View File
@@ -23,6 +23,7 @@ impl Session {
self.pending = None;
self.typing = None;
self.also_selected.clear();
self.member = None;
self.column = None;
self.column_mode = false;
self.widened = None;
@@ -155,6 +156,7 @@ impl Session {
#[cfg(not(target_arch = "wasm32"))]
recovery_slot: _,
also_selected: _,
member: _,
// Column selection and a widened selection's ends, like `sel`'s shape: valid only while
// `sel` matches them.
column: _,
@@ -211,6 +213,7 @@ impl Session {
self.doc = doc;
self.sel = sel;
self.also_selected.clear();
self.member = None;
self.history.truncate(history_len);
self.redo = redo;
self.typing_open = typing_open;
+2
View File
@@ -141,6 +141,8 @@ impl Session {
self.page_hint = page_hint;
self.column_mode = column_mode;
self.math = math;
// A picked Drawing Canvas member (#2637) is not part of a view's saved selection.
self.member = None;
if current {
self.hf_return = hf_return;
self.typing_open = typing_open;
+8 -1
View File
@@ -332,7 +332,14 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
prev_end = push_para(&mut items, (story, &path), &pl, (0, pl.lines.len()), (0.0, y + lead), width, edges);
// Pictures, shapes and text boxes: drawn, their areas and text boxes' text.
if !p.objects.is_empty() {
let at = ObjFrame { page: frame, col: (0.0, width), para_y: para_top, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) };
let at = ObjFrame {
page: frame,
col: (0.0, width),
para_y: para_top,
table_chr: table.map(|t| &t.chr),
pinned: Some(&pinned),
late: None,
};
let (back, front) = place_objects(
ctx,
&ObjectPlacement {
+263 -12
View File
@@ -3,8 +3,8 @@ use super::page::{PageBuilder, PageFrame};
use super::placed::Placed;
use super::table_flow::footer_table_wraps;
use super::{
Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, ParaLayout, Paragraph, Path, Point, Rect, Spin,
StoryRef, Wrap, WrapText, contour, display, para, textbox,
Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, LineEnd, ParaLayout, Paragraph, Path, Point,
Rect, Spin, StoryRef, Wrap, WrapText, contour, display, para, textbox,
};
/// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden
@@ -336,14 +336,133 @@ fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> {
/// page's wrap areas (its area as placed now). Without `place` (a later piece of the paragraph
/// broken again for another column) the floats stay where its first piece placed and drew them:
/// they aren't placed again in the new column, and their record for the page they are drawn on
/// ([`EarlyFloats`]) stands.
/// ([`EarlyFloats`]) stands. Floats anchored on a later page ([`late_floats`]), and those in
/// `force`, aren't placed here: the paragraph is laid out without them, and they're returned.
pub(crate) fn anchor_floats(
ctx: &mut Ctx,
pb: &mut PageBuilder,
anchor: FloatAnchor,
force: &[usize],
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>, Vec<usize>) {
let FloatAnchor { p, block, y0, place, .. } = anchor;
ctx.fields.page = pb.number;
// Each layout of it here counts its automatic equation numbers from the same place.
let eq0 = ctx.eq_count;
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, force);
if !place || pb.web || p.objects.is_empty() {
return (rects, pl, rel, force.to_vec());
}
// Not those left out, nor those the page started with (#2048 settled them here).
let page = pb.pages.len().saturating_sub(1);
let seeded: Vec<usize> =
pb.early.get(&page).map(|v| v.iter().take(pb.seeded).filter(|(b, ..)| *b == block).map(|(_, k, _)| *k).collect()).unwrap_or_default();
let (bottom, more, col_top) = (pb.bottom, pb.cols.len().saturating_sub(pb.col + 1), pb.top);
let fit = |pl: &ParaLayout| fitting(pl, 0, y0 + pl.lines.first().map_or(0.0, |l| l.top.max(0.0)), bottom, (more, col_top));
let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.hide_deleted);
let mut late: Vec<usize> =
late_floats(p, &pl, fit(&pl)).into_iter().chain(force.iter().copied()).filter(|oi| !left_out(*oi) && !seeded.contains(oi)).collect();
late.sort_unstable();
late.dedup();
if late.as_slice() == force {
return (rects, pl, rel, late);
}
ctx.eq_count = eq0;
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, &late);
// Without them in the way more of the paragraph may fit, and some of their anchors with it.
let still = late_floats(p, &pl, fit(&pl));
let fewer: Vec<usize> = late.iter().copied().filter(|oi| force.binary_search(oi).is_ok() || still.binary_search(oi).is_ok()).collect();
if fewer.len() == late.len() {
return (rects, pl, rel, late);
}
ctx.eq_count = eq0;
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, &fewer);
(rects, pl, rel, fewer)
}
/// The floating objects of `p` (laid out as `pl`) whose anchor line ([`anchor_lines`]) is at or
/// after `fit`, in order: Word draws them on the page their anchor line is on (#4250).
pub(crate) fn late_floats(p: &Paragraph, pl: &ParaLayout, fit: usize) -> Vec<usize> {
let lines = anchor_lines(p, pl);
let late =
|(oi, o): (usize, &InlineObject)| (floating(o).is_some() && lines.get(oi).copied().flatten().is_some_and(|li| li >= fit)).then_some(oi);
p.objects.iter().enumerate().filter_map(late).collect()
}
/// The end of the lines of `pl` from `l0` that fit on the page: line `l0`'s top at `top` in the
/// column, whose bottom is at `bottom`, then `more` columns from `col_top` (at least one line; a
/// page break ends them, a column break its column).
pub(crate) fn fitting(pl: &ParaLayout, l0: usize, top: f32, bottom: f32, (mut more, col_top): (usize, f32)) -> usize {
let Some(first) = pl.lines.get(l0) else { return l0 };
// A line's place in its column: `shift` plus its top.
let (mut shift, mut start, mut fit) = (top - first.top, l0, l0 + 1);
for (li, l) in pl.lines.iter().enumerate().skip(l0) {
if li > start && shift + l.top + l.height > bottom + 0.01 {
if more == 0 {
break;
}
(more, start, shift) = (more - 1, li, col_top - l.top);
}
fit = li + 1;
match l.end {
LineEnd::PageBreak => break,
LineEnd::ColumnBreak if more == 0 => break,
LineEnd::ColumnBreak => {
more -= 1;
start = li + 1;
shift = pl.lines.get(start).map_or(shift, |n| col_top - n.top);
}
_ => {}
}
}
fit
}
/// Place late float `oi` of body paragraph `p` ([`LateFloats`]) in the current column,
/// positioned from `top`, where the paragraph continues; its wrap area joins the page's.
pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi: usize, top: f32, late: &mut LateFloats) {
let Some(o) = p.objects.get(oi) else { return };
let Some((w, h, float)) = floating(o) else { return };
let h = ctx.float_height(o, w, h);
let frame = PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror };
let rect = float_rect(Some(frame), (pb.col_x(), pb.col_w()), top, w, h, float);
let outline = ctx.wrap_outline(o);
let area = wrap_area(rect, float, outline.as_deref().map(Vec::as_slice)).map(|a| {
let bounds = a.0;
pb.excl.push(a);
bounds
});
late.placed.insert(oi, Some(LatePlace { rect, top, spot: (pb.pages.len(), pb.col), drawn: false, area }));
}
/// The line of `pl` each object of `p` goes with (by object index; None if it isn't laid out):
/// the one holding its anchor, or for anchors on a line after a page or column break that holds
/// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the
/// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after
/// them they are on the new page with it (#4262).
pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec<Option<usize>> {
let mut out = vec![None; p.objects.len()];
for (li, l) in pl.lines.iter().enumerate() {
let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default();
let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak))
&& !clusters.iter().any(para::takes_room);
for c in clusters {
let para::ClKind::Object(oi) = c.kind else { continue };
if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) {
*slot = Some(if lead { li.saturating_sub(1) } else { li });
}
}
}
out
}
/// [`anchor_floats`], leaving out the floats in `late` (in order).
fn anchor_floats_but(
ctx: &mut Ctx,
pb: &mut PageBuilder,
anchor: FloatAnchor,
late: &[usize],
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>) {
let FloatAnchor { p, block, y0, before, excl_mark, label, place } = anchor;
ctx.fields.page = pb.number;
let (col_x, width) = (pb.col_x(), pb.col_w());
let mut rects = HashMap::new();
if !pb.web && place {
@@ -363,6 +482,10 @@ pub(crate) fn anchor_floats(
if left_out(oi) {
continue;
}
if late.binary_search(&oi).is_ok() {
ctx.floats_above.remove(&(block, oi));
continue;
}
let h = ctx.float_height(o, w, h);
// Positions relative to the paragraph start above its space before (Word for Windows).
let mut r = float_rect(Some(frame), (col_x, width), y0 - before, w, h, float);
@@ -414,6 +537,123 @@ pub(crate) struct ObjFrame<'a> {
/// Floats placed on the page from a header or footer cell (by object index, in page
/// coordinates): their items go to [`Ctx::pin`].
pub(crate) pinned: Option<&'a HashMap<usize, Rect>>,
/// A body paragraph's floats that go on a later page or column than its first piece.
pub(crate) late: Option<&'a LateFloats>,
}
/// Where a late float ([`LateFloats`]) was placed: its rect, the top it's positioned from, the
/// page and column, `(pages, col)`, whether it's drawn, and the bounds of its wrap area.
#[derive(Clone, Copy)]
pub(crate) struct LatePlace {
pub(crate) rect: Rect,
pub(crate) top: f32,
pub(crate) spot: (usize, usize),
pub(crate) drawn: bool,
pub(crate) area: Option<Rect>,
}
/// A body paragraph's floating objects anchored on a later page than its first line
/// ([`late_floats`]): Word draws them there, positioned from where the paragraph continues
/// (#4250). Each is placed when the paragraph gets there, and drawn with that piece.
pub(crate) struct LateFloats {
/// By object index, where it was placed, once it is.
pub(crate) placed: HashMap<usize, Option<LatePlace>>,
/// The page and column of the piece being placed.
pub(crate) piece: (usize, usize),
/// [`anchor_lines`], and the paragraph layout they're for.
pub(crate) lines: Option<(Arc<ParaLayout>, Vec<Option<usize>>)>,
/// Those a piece let go ([`LateFloats::release`]), and those it keeps all the same
/// ([`LateFloats::keep_back`]): (piece, object index).
pub(crate) held: Vec<((usize, usize), usize)>,
pub(crate) kept: Vec<((usize, usize), usize)>,
}
impl LateFloats {
pub(crate) fn new(late: &[usize]) -> Self {
LateFloats { placed: late.iter().map(|oi| (*oi, None)).collect(), piece: (0, 0), lines: None, held: Vec::new(), kept: Vec::new() }
}
/// [`anchor_lines`] for `pl`.
pub(crate) fn refresh(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>) {
if !self.lines.as_ref().is_some_and(|(of, _)| Arc::ptr_eq(of, pl)) {
self.lines = Some((pl.clone(), anchor_lines(p, pl)));
}
}
/// The line object `oi` goes with ([`anchor_lines`]).
pub(crate) fn anchor(&self, oi: usize) -> Option<usize> {
self.lines.as_ref().and_then(|(_, v)| v.get(oi).copied().flatten())
}
/// Those placed with this piece whose anchor line is past `end` after all (notes, widow
/// control or a row kept together took lines [`fitting`] counted) wait for the next piece,
/// their wrap areas out of `excl`. Whether there were any.
pub(crate) fn release(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, end: usize, excl: &mut Vec<WrapArea>) -> bool {
self.refresh(p, pl);
let piece = self.piece;
let out: Vec<usize> = self
.placed
.iter()
.filter(|(oi, at)| at.is_some_and(|a| a.spot == piece && !a.drawn) && !self.kept.contains(&(piece, **oi)))
.filter(|(oi, _)| self.anchor(**oi).is_some_and(|li| li >= end))
.map(|(oi, _)| *oi)
.collect();
for oi in &out {
if let Some(Some(a)) = self.placed.insert(*oi, None)
&& let Some(k) = a.area.and_then(|r| excl.iter().rposition(|w| w.0 == r))
{
excl.remove(k);
}
self.held.push((piece, *oi));
}
!out.is_empty()
}
/// Those this piece let go whose anchor line came back into `lines` without them (their own
/// wrap pushed it on): kept with this piece after all, as before. Whether there were any.
pub(crate) fn keep_back(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, lines: std::ops::Range<usize>) -> bool {
self.refresh(p, pl);
let piece = self.piece;
let back: Vec<_> =
self.held.iter().filter(|(pc, oi)| *pc == piece && self.anchor(*oi).is_some_and(|li| lines.contains(&li))).copied().collect();
self.held.retain(|h| !back.contains(h));
self.kept.extend(&back);
!back.is_empty()
}
/// Those to place with this piece: not placed yet (nor let go by it), or placed for a column
/// the paragraph then didn't go into, with their anchor line in `lines`, in order.
pub(crate) fn due(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, lines: std::ops::Range<usize>) -> Vec<usize> {
self.refresh(p, pl);
let mut due: Vec<usize> = self
.placed
.iter()
.filter(|(_, at)| at.is_none_or(|a| a.spot != self.piece && !a.drawn))
.filter(|(oi, _)| !self.held.contains(&(self.piece, **oi)) && self.anchor(**oi).is_some_and(|li| lines.contains(&li)))
.map(|(oi, _)| *oi)
.collect();
due.sort_unstable();
due
}
/// Late float `oi` if it's drawn with this piece.
pub(crate) fn here(&self, oi: usize) -> Option<LatePlace> {
self.placed.get(&oi).copied().flatten().filter(|a| a.spot == self.piece && !a.drawn)
}
/// The lines holding the anchors of those drawn with this piece (an anchor right after a
/// break is on the line after its [`anchor_lines`] one).
pub(crate) fn lines_here(&self) -> Vec<usize> {
self.placed.keys().filter(|oi| self.here(**oi).is_some()).filter_map(|oi| self.anchor(*oi)).flat_map(|li| [li, li + 1]).collect()
}
/// Those drawn with this piece are drawn.
pub(crate) fn drew(&mut self) {
let piece = self.piece;
for a in self.placed.values_mut().flatten().filter(|a| a.spot == piece) {
a.drawn = true;
}
}
}
/// What [`anchor_floats`] anchors: paragraph `p` (body block `block`) starting at `y0` with
@@ -461,15 +701,15 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
let Some(fl) = pl.lines.get(l0) else { return (Vec::new(), Vec::new()) };
// Objects left out of the layout (hidden, or deleted in the final text) aren't placed.
let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.hide_deleted);
// A floating object belongs to its paragraph, not to its line: Word puts it on the page the
// paragraph starts on (a cover page is a paragraph holding a page break and then the text
// box with the title; the box stays on the cover). So the paragraph's first piece places
// every floating object, whichever of its lines holds the anchor, and later pieces none;
// inline objects go with their own line.
// The paragraph's first piece draws its floating objects, whichever of its lines holds the
// anchor, except those anchored on a later page (`at.late`): the piece there draws them
// (#4250). Inline objects go with their own line.
let first = l0 == 0;
let late = at.late.filter(|l| !l.placed.is_empty());
let late_lines = late.map(LateFloats::lines_here).unwrap_or_default();
for li in 0..pl.lines.len() {
let in_piece = (l0..l1).contains(&li);
if !in_piece && !first {
if !in_piece && !first && !late_lines.contains(&li) {
continue;
}
let Some(line) = pl.lines.get(li) else { continue };
@@ -482,12 +722,23 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
let Some(obj) = p.objects.get(oi) else { continue };
let Some((w, h, float)) = obj.frame() else { continue };
let floating = float.wrap != Wrap::Inline;
if if floating { !first } else { !in_piece } {
let is_late = floating && late.is_some_and(|l| l.placed.contains_key(&oi));
let late_here = late.and_then(|l| l.here(oi)).filter(|_| floating);
let skip = if is_late {
late_here.is_none()
} else if floating {
!first
} else {
!in_piece
};
if skip {
continue;
}
let pinned = at.pinned.and_then(|m| m.get(&oi)).copied().filter(|_| floating);
let rect = if let Some(r) = pinned {
r
} else if let Some(l) = late_here {
l.rect
} else if floating {
match floats.get(&oi) {
Some(r) => *r,
@@ -522,7 +773,7 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
off: c.start,
text_box: shown,
wrap: float.wrap,
origin: Point::new(at.col.0, at.para_y),
origin: Point::new(at.col.0, late_here.map_or(at.para_y, |l| l.top)),
});
// Its text (a group: its text boxes', each where the group's turn carries it; the
// text itself stays upright), unless the box budget says no (a box inside itself,
+116 -11
View File
@@ -1,7 +1,10 @@
use super::boxes::{BoxInput, BoxLayout, Edges, joined, layout_box, push_para};
use super::cache::has_page_number_fields;
use super::ctx::Ctx;
use super::floats::{FloatAnchor, ObjFrame, ObjectPlacement, anchor_floats, body_wraps, continued_exclusions, place_objects};
use super::floats::{
FloatAnchor, LateFloats, ObjFrame, ObjectPlacement, anchor_floats, body_wraps, continued_exclusions, fitting, late_floats, place_late,
place_objects,
};
use super::notes::{DECOR_STORY, NoteBox, para_notes};
use super::page::{PageBuilder, PageFrame, item_bottom};
use super::placed::Placed;
@@ -172,6 +175,15 @@ struct Flow<'a> {
wrap_relays: usize,
/// The first line not placed yet.
l0: usize,
/// Its floats anchored on a later page or column than its first piece (#4250).
late: LateFloats,
/// Whether the floats past the first piece were checked ([`Flow::recheck_late`]), and those
/// it held back.
rechecked: bool,
forced: Vec<usize>,
/// The space before it at the top of the piece being placed (a paragraph starting with a page
/// break has it on the new page): late floats there are positioned from above it.
space_here: f32,
}
impl<'a> Flow<'a> {
@@ -185,8 +197,8 @@ impl<'a> Flow<'a> {
let guess = pb.y + space_before;
let label = ctx.next_label(p);
let eq_start = ctx.eq_count;
let (float_rects, pl, rel) =
anchor_floats(ctx, pb, FloatAnchor { p, block, y0: guess, before: space_before, excl_mark, label: &label, place: true });
let (float_rects, pl, rel, late) =
anchor_floats(ctx, pb, FloatAnchor { p, block, y0: guess, before: space_before, excl_mark, label: &label, place: true }, &[]);
Flow {
p,
block,
@@ -206,6 +218,10 @@ impl<'a> Flow<'a> {
page_relays: 0,
wrap_relays: 0,
l0: 0,
late: LateFloats::new(&late),
rechecked: false,
forced: Vec::new(),
space_here: 0.0,
}
}
@@ -213,16 +229,19 @@ impl<'a> Flow<'a> {
self.pl.lines.len()
}
/// Place the floats and lay the paragraph out again from `y0`, `before` the space before it.
fn reanchor(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, (y0, before): (f32, f32)) {
let (rects, pl, rel) = again(ctx, self.eq_start, |ctx| {
/// Place the floats and lay the paragraph out again from `y0`, `before` the space before it,
/// leaving those in `force` for a later piece.
fn reanchor(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, (y0, before): (f32, f32), force: &[usize]) {
let (rects, pl, rel, late) = again(ctx, self.eq_start, |ctx| {
anchor_floats(
ctx,
pb,
FloatAnchor { p: self.p, block: self.block, y0, before, excl_mark: self.excl_mark, label: &self.label, place: true },
force,
)
});
(self.float_rects, self.pl, self.rel) = (rects, pl, rel);
self.late = LateFloats::new(&late);
(self.laid_page, self.laid_width) = (pb.number, pb.col_w());
}
@@ -473,8 +492,10 @@ impl<'a> Flow<'a> {
pb.advance(self.block, self.body_top);
if self.l0 == 0 {
let y0 = pb.y;
self.reanchor(ctx, pb, (y0, 0.0));
self.reanchor(ctx, pb, (y0, 0.0), &[]);
self.anchor_y = y0;
// Its floats are checked again on the new page.
(self.rechecked, self.forced) = (false, Vec::new());
}
}
@@ -507,6 +528,13 @@ impl<'a> Flow<'a> {
}
let end = push_para(&mut items, (StoryRef::Body, &[self.block as u32]), &self.pl, (l0, l1), (x, y), self.width, edges);
pb.para_end = Some((pb.pages.len(), pb.col, end));
// Late floats whose anchor line came here after all are placed with these lines.
self.late.piece = (pb.pages.len(), pb.col);
if !pb.web && !self.late.placed.is_empty() {
let top = if l0 == 0 { self.anchor_y } else { y - self.space_here };
self.place_due(ctx, pb, 0..l1, top);
}
self.space_here = 0.0;
// Pictures, shapes and text boxes in these lines (web view: floats placed by this piece).
let at = ObjFrame {
page: Some(PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }),
@@ -514,6 +542,7 @@ impl<'a> Flow<'a> {
para_y: if pb.web { y } else { self.anchor_y },
table_chr: None,
pinned: None,
late: Some(&self.late),
};
let (behind, front) = place_objects(
ctx,
@@ -529,6 +558,7 @@ impl<'a> Flow<'a> {
depth: 0,
},
);
self.late.drew();
items.extend(front);
items.extend(line_numbers(ctx, pb, &self.pl, (l0, l1), y));
add_items(pb, behind, items);
@@ -555,7 +585,8 @@ impl<'a> Flow<'a> {
}
Some(LineEnd::PageBreak) if starts_with_break && !pb.web => {
pb.new_page(block, body_top);
pb.y += self.pl.rp.space_before.max(0.0);
self.space_here = self.pl.rp.space_before.max(0.0);
pb.y += self.space_here;
}
Some(LineEnd::PageBreak) => pb.new_page(block, body_top),
Some(LineEnd::ColumnBreak) => {
@@ -578,8 +609,8 @@ impl<'a> Flow<'a> {
let y0 = pb.y;
// The floats were placed and drawn with the first piece: they stay there.
let (p, block, excl_mark, eq_start, label) = (self.p, self.block, self.excl_mark, self.eq_start, &self.label);
let (rects, relaid, relaid_rel) = with_page_map(ctx, &mut self.page_map, |ctx| {
again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, FloatAnchor { p, block, y0, before: 0.0, excl_mark, label, place: false }))
let (rects, relaid, relaid_rel, _) = with_page_map(ctx, &mut self.page_map, |ctx| {
again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, FloatAnchor { p, block, y0, before: 0.0, excl_mark, label, place: false }, &[]))
});
if let Some(k) = relaid.lines.iter().position(|l| l.start == resume) {
(self.float_rects, self.pl, self.rel) = (rects, relaid, relaid_rel);
@@ -589,6 +620,72 @@ impl<'a> Flow<'a> {
}
}
/// Late floats anchored in the lines that fit in this column are placed here, so these lines
/// are broken again around them ([`Flow::relay_wrap`]).
fn place_due_late(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder) {
if self.l0 == 0 || pb.web || self.late.placed.is_empty() {
return;
}
self.late.piece = (pb.pages.len(), pb.col);
let lines = self.l0..fitting(&self.pl, self.l0, pb.y, pb.bottom, (0, pb.top));
self.place_due(ctx, pb, lines, pb.y - self.space_here);
}
/// Place the late floats due with this piece whose anchor line is in `lines`, positioned from
/// `top`.
fn place_due(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, lines: std::ops::Range<usize>, top: f32) {
for oi in self.late.due(self.p, &self.pl, lines) {
place_late(ctx, pb, self.p, oi, top, &mut self.late);
}
}
/// Late floats placed for lines that went on after all (past `l1`) wait for the next piece;
/// one whose own wrap pushed its line on stays, as before. Whether the lines from `l0` are to
/// be broken again without them. Does nothing once the wrap relays are used up
/// ([`MAX_WRAP_RELAYS`]).
fn settle_late(&mut self, pb: &mut PageBuilder, l1: usize) -> bool {
let l0 = self.l0;
if l0 == 0 || pb.web || self.late.placed.is_empty() || self.wrap_relays >= MAX_WRAP_RELAYS {
return false;
}
let end = if l1 == l0 && pb.fresh() { l0 + 1 } else { l1 }; // what the overflow keeps
let moved = self.late.release(self.p, &self.pl, end, &mut pb.excl) || self.late.keep_back(self.p, &self.pl, l0..end);
moved && l1 > l0
}
/// The first piece's lines end at `l1` on this page. Floats anchored past them, on the next
/// page, though [`anchor_floats`] thought they would fit here (footnotes, widow control…),
/// are held back after all, once; and those held back whose anchor came back once they were
/// out of the way are placed here as before, with the text wrapping around them rather than
/// over it (before the notes of these lines are kept: the wrap can push their references on).
/// Whether the paragraph was laid out again.
fn recheck_late(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, l1: usize) -> bool {
if self.l0 != 0 || pb.web {
return false;
}
if l1 < self.n() && !self.rechecked && pb.col + 1 >= pb.cols.len() {
self.rechecked = true;
self.forced = late_floats(self.p, &self.pl, l1).into_iter().filter(|oi| !self.late.placed.contains_key(oi)).collect();
if !self.forced.is_empty() {
let mut force = self.forced.clone();
force.extend(self.late.placed.keys());
force.sort_unstable();
let top = (pb.y, pb.y - self.anchor_y);
self.reanchor(ctx, pb, top, &force);
return true;
}
}
let back = if self.forced.is_empty() { Vec::new() } else { self.late.due(self.p, &self.pl, 0..l1) };
if !back.iter().any(|oi| self.forced.contains(oi)) {
return false;
}
// The others are still held back.
self.forced.retain(|oi| !back.contains(oi));
let (keep, top) = (std::mem::take(&mut self.forced), (pb.y, pb.y - self.anchor_y));
self.reanchor(ctx, pb, top, &keep);
true
}
/// Leave the space after the paragraph, for the next one to merge with.
fn finish(&self, pb: &mut PageBuilder, rooms: Rooms) {
// A mark that stayed with its page break keeps its space after on that page too, and its
@@ -730,10 +827,11 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
// A different top only matters when there is something to wrap around.
if spot(pb) != placed_at || ((pb.y - f.guess).abs() > 0.01 && !pb.excl.is_empty()) {
let y0 = pb.y;
f.reanchor(ctx, pb, (y0, applied_before));
f.reanchor(ctx, pb, (y0, applied_before), &[]);
f.anchor_y = y0 - applied_before;
}
while f.l0 < f.n() {
f.place_due_late(ctx, pb);
f.relay_wrap(ctx, pb);
f.relay_pages(ctx, pb);
// What is laid out for these lines from here (their footnotes) is on this page.
@@ -744,6 +842,9 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
let notes = para_notes(ctx.doc, p, &f.pl);
let mut fit = f.fit_lines(ctx, pb, &notes, lead);
fit.l1 = f.settled_end(pb, &fit);
if f.settle_late(pb, fit.l1) {
continue;
}
if fit.l1 == f.l0 {
if !pb.fresh() {
f.move_on(ctx, pb);
@@ -751,6 +852,9 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
}
fit.l1 = f.l0 + 1; // can't fit even one line on an empty page: overflow
}
if f.recheck_late(ctx, pb, fit.l1) {
continue;
}
let (forced, mark_with_break) = (fit.forced, fit.mark_with_break);
f.emit(ctx, pb, rooms, (lead, fit, &notes));
f.after_lines(ctx, pb, forced, mark_with_break);
@@ -811,6 +915,7 @@ pub(crate) fn place_outline_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragr
para_y: y,
table_chr: None,
pinned: None,
late: None,
};
let (behind, front) = place_objects(
ctx,
+4
View File
@@ -72,6 +72,9 @@ pub struct ObjectHit {
pub wrap: Wrap,
/// Where column- and paragraph-relative offsets start (see [`Placed::Object`]).
pub origin: Point,
/// A member of a Drawing Canvas, by its index in the canvas, when the hit is that member
/// (its frame inside the canvas at `pos`) rather than the canvas. Layout gives `None`.
pub member: Option<usize>,
}
/// Object positions in one layout, retaining each page occurrence for page-hinted lookups.
@@ -149,6 +152,7 @@ fn objects_in(items: &[Placed], index: usize) -> impl Iterator<Item = ObjectHit>
text_box: *text_box,
wrap: *wrap,
origin: *origin,
member: None,
}),
_ => None,
})
+1
View File
@@ -38,6 +38,7 @@ pub(crate) use chars::{ligature_features, remap_range, thai_run_face};
pub use grid::{Exclusion, Grid, is_east_asian};
#[cfg(test)]
use lines::break_lines;
pub(crate) use lines::takes_room;
pub use model::{CellText, ClKind, Cluster, Enclosed, Glyph, Label, LabelRun, Line, LineEnd, PARA_MARK_WIDTH, ParaEnv, RubyLayout, StyleRun, VisCl};
pub use paragraph::layout_para;
#[cfg(test)]
+1 -1
View File
@@ -109,7 +109,7 @@ fn has_content(pl: &ParaLayout, c0: usize, j: usize) -> bool {
/// control's start, a bookmark) and floating objects' anchors take no room and don't count (Word:
/// a picture a little wider than the line, after an anchor, stays on the first line rather than
/// leaving the anchor a line of its own).
fn takes_room(c: &Cluster) -> bool {
pub(crate) fn takes_room(c: &Cluster) -> bool {
c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0)
}
+343 -3
View File
@@ -6036,9 +6036,8 @@ fn float_follows_its_paragraph_to_the_next_page() {
assert!(para.lines[0].left >= 144.0, "text wraps beside it: {}", para.lines[0].left);
}
/// A floating object belongs to its paragraph, not to the line holding its anchor: Word draws
/// it on the page the paragraph starts on. A Word cover page is one paragraph holding a page
/// break and then the page-anchored text box with the title; the box stays on the cover.
/// A Word cover page is one paragraph holding a page break and then the page-anchored text box
/// with the title: with nothing after its anchor, the box stays on the cover.
#[test]
fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() {
let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK));
@@ -6051,6 +6050,347 @@ fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() {
assert!(shapes(&l.pages[1].items).is_empty(), "and not again on page two");
}
/// #4262: a bookmark after the box's anchor (or a comment's end, a field's) takes no room: the box
/// still stays on the cover. An inline picture after it does take room: it goes on to page two,
/// and the box with it.
#[test]
fn a_cover_box_stays_on_the_cover_with_a_bookmark_after_it_but_not_with_a_picture() {
let cover = |after: InlineObject| {
let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK));
d.insert_object(&Pos::body(0, 1), after, &Default::default()).unwrap();
d.insert_object(&Pos::body(0, 1), rect_shape(200.0, 100.0, page_float(100.0, 300.0)), &Default::default()).unwrap();
let l = lay(&d);
// The floating box; the inline picture is 20 pt wide.
let boxes = |pi: usize| shapes(&l.pages[pi].items).iter().filter(|r| r.w > 100.0).count();
(boxes(0), boxes(1))
};
assert_eq!(cover(InlineObject::BookmarkEnd { name: "_GoBack".into() }), (1, 0), "with a bookmark: on the cover");
assert_eq!(cover(rect_shape(20.0, 20.0, Default::default())), (0, 1), "with an inline picture: on page two");
}
/// #4262: with text after the anchor, though, the line after the break is on the next page, and
/// so is the float anchored at its start. Observed in Word for Mac: a shape dragged beside the
/// first line after a page break inside a paragraph is anchored right after the break and stays
/// on the new page, positioned from the paragraph's top there.
#[test]
fn a_float_anchored_before_text_after_a_page_break_is_drawn_on_the_next_page() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let text = "The quick brown fox jumps over the lazy dog. ";
let mut d = Document::from_text(&format!("{}{}{}", text.repeat(5), wordcraft_doc::para::PAGE_BREAK, text.repeat(5)));
let at = text.len() * 5 + wordcraft_doc::para::PAGE_BREAK.len_utf8();
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 60.0, y: 1.0, ..Default::default() };
d.insert_object(&Pos::body(0, at), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert_eq!(l.pages.len(), 2);
assert!(shapes(&l.pages[0].items).is_empty(), "not on page one");
let (top, ..) = lines_on(&l, 1)[0];
assert_eq!(shapes(&l.pages[1].items).len(), 1, "drawn once");
let r = shapes(&l.pages[1].items)[0];
assert!((r.y - (top + 1.0)).abs() < 0.5, "1 pt below the paragraph's top on page two ({top}): {r:?}");
}
/// The lines of body paragraph 0 on page `pi`: each one's (top on the page, left, start).
fn lines_on(l: &DocLayout, pi: usize) -> Vec<(f32, f32, usize)> {
let mut out = Vec::new();
for it in &l.pages[pi].items {
if let Placed::Lines { para, l0, l1, y, story: StoryRef::Body, path, .. } = it
&& path.0 == [0]
{
let first = para.lines[*l0].top;
out.extend(para.lines[*l0..*l1].iter().map(|li| (y + li.top - first, li.left, li.start)));
}
}
out
}
/// #4250: a floating object anchored in a line of a long paragraph on a later page is drawn on
/// that page, as Word does (its anchor line's page), not on the page the paragraph starts on.
/// That page's text wraps around it; the page before is laid out as without it.
#[test]
fn a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page() {
use wordcraft_doc::para::Wrap;
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let plain = lay(&d);
assert!(plain.pages.len() >= 3, "pages {}", plain.pages.len());
let anchor = lines_on(&plain, 1)[5].2;
let f = wordcraft_doc::para::Float { wrap: Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert!(shapes(&l.pages[0].items).is_empty(), "not on the page the paragraph starts on");
assert_eq!(l.pages.iter().map(|p| shapes(&p.items).len()).sum::<usize>(), 1, "drawn once");
let on_two = shapes(&l.pages[1].items);
assert_eq!(on_two.len(), 1, "{on_two:?}");
assert!((on_two[0].x - 72.0).abs() < 0.5 && (on_two[0].y - 300.0).abs() < 0.5, "{:?}", on_two[0]);
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the text beside it wraps: {beside:?}");
let starts = |l: &DocLayout| lines_on(l, 0).iter().map(|(_, left, start)| (*left, *start)).collect::<Vec<_>>();
assert_eq!(starts(&l), starts(&plain), "page one is as without it");
// Its hit area is there too, for selecting and dragging it.
let hit = l.object(&Pos::body(0, anchor), 0).expect("laid out");
assert_eq!(hit.page, 1);
}
/// #4250: positioned from its paragraph, a float anchored on a later page of the paragraph is
/// measured from where the paragraph continues on that page (Word saves such a picture dragged
/// onto page two as `relativeFrom="paragraph"` with its offset from that page's top line).
#[test]
fn a_float_from_its_paragraph_on_a_later_page_is_measured_from_the_paragraph_s_top_there() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let plain = lay(&d);
let (top, _, _) = lines_on(&plain, 1)[0];
let anchor = lines_on(&plain, 1)[8].2;
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 120.0, y: 150.0, dist: 9.0, ..Default::default() };
d.insert_object(&Pos::body(0, anchor), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert!(shapes(&l.pages[0].items).is_empty());
let r = shapes(&l.pages[1].items)[0];
assert!((r.y - (top + 150.0)).abs() < 0.5, "150 pt below the paragraph's top on page two ({top}): {r:?}");
assert!((r.x - 192.0).abs() < 0.5, "{r:?}");
let hit = l.object(&Pos::body(0, anchor), 1).expect("laid out");
assert!((hit.origin.y - top).abs() < 0.5, "dragging measures from there too: {:?}", hit.origin);
}
/// #4250: a paragraph starting with a page break has its space before on the new page; a float
/// from its paragraph anchored there is still measured from the paragraph's top, above that
/// space, as on its first page.
#[test]
fn a_float_after_a_leading_page_break_is_measured_from_above_the_space_before() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let text = format!("Page one.\n{}{}", wordcraft_doc::para::PAGE_BREAK, "The quick brown fox jumps over the lazy dog. ".repeat(20));
let mut d = Document::from_text(&text);
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(1).map(std::sync::Arc::make_mut) {
p.props.space_before = Some(24.0);
}
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, y: 150.0, dist: 9.0, ..Default::default() };
d.insert_object(&Pos::body(1, 200), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert!(shapes(&l.pages[0].items).is_empty());
let r = shapes(&l.pages[1].items)[0];
assert!((r.y - (72.0 + 150.0)).abs() < 0.5, "150 pt below the top margin: {r:?}");
}
/// #4250, as observed in Word for Mac: lines added at the top of page one push the text down,
/// but a picture positioned from its paragraph on page two stays where it is there.
#[test]
fn text_added_on_page_one_leaves_a_float_on_page_two_where_it_is() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let anchor = lines_on(&lay(&d), 1)[8].2;
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, y: 150.0, ..Default::default() };
d.insert_object(&Pos::body(0, anchor), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
let before = shapes(&lay(&d).pages[1].items);
for _ in 0..3 {
d.body.insert(0, wordcraft_doc::para_block(wordcraft_doc::Paragraph::new()));
}
let l = lay(&d);
assert!(shapes(&l.pages[0].items).is_empty());
assert_eq!(shapes(&l.pages[1].items), before);
}
/// #4250: anchored on the third page of a paragraph, a float is drawn on the third page.
#[test]
fn a_float_anchored_two_pages_into_its_paragraph_is_drawn_on_that_page() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
let plain = lay(&d);
assert!(plain.pages.len() >= 4, "pages {}", plain.pages.len());
let anchor = lines_on(&plain, 2)[3].2;
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
assert_eq!(&per_page[..3], [0, 0, 1], "{per_page:?}");
assert_eq!(per_page.iter().sum::<usize>(), 1);
}
/// A long paragraph with a long footnote on page one, and a page-placed square float anchored at
/// line `k` of page two as laid out without it.
fn footnoted_float(k: usize) -> DocLayout {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
note.props.space_after = Some(0.0);
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
d.insert_object(
&Pos::body(0, 4),
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() },
&Default::default(),
)
.unwrap();
let anchor = lines_on(&lay(&d), 1)[k].2;
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
lay(&d)
}
/// #4250: a footnote on page one leaves less room there than its lines alone suggest; a float
/// anchored a few lines into page two is drawn on page two all the same.
#[test]
fn a_float_anchored_just_past_a_page_of_footnotes_is_drawn_on_the_next_page() {
let l = footnoted_float(3);
assert!(shapes(&l.pages[0].items).is_empty(), "not on page one");
assert_eq!(shapes(&l.pages[1].items).len(), 1);
}
/// #4250: anchored in page two's first line, which goes back to page one once the float no longer
/// wraps the text there, a float stays on page one with the text wrapping around it, not over it.
#[test]
fn a_float_whose_anchor_comes_back_to_page_one_stays_there_with_the_text_around_it() {
let l = footnoted_float(0);
assert_eq!(l.pages.iter().map(|p| shapes(&p.items).len()).collect::<Vec<_>>()[..2], [1, 0]);
let beside: Vec<_> = lines_on(&l, 0).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the text wraps around it: {beside:?}");
}
/// #4250: the text wrapping around that float again pushes a footnote reference beside its anchor
/// back to page two; the note goes with it, once, not left on page one as well.
#[test]
fn a_footnote_pushed_on_by_a_float_coming_back_is_on_its_reference_s_page_only() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let note = |text: &str| {
let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()).styled("FootnoteText");
p.props.space_after = Some(0.0);
vec![wordcraft_doc::para_block(p)]
};
let noteref = |id| InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
let long = d.add_part(wordcraft_doc::PartKind::Footnote, note(&"The note text runs on for a while. ".repeat(30)));
d.insert_object(&Pos::body(0, 4), noteref(long), &Default::default()).unwrap();
let anchor = lines_on(&lay(&d), 1)[0].2;
let short = d.add_part(wordcraft_doc::PartKind::Footnote, note("A short note."));
d.insert_object(&Pos::body(0, anchor), noteref(short), &Default::default()).unwrap();
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert_eq!(shapes(&l.pages[0].items).len(), 1, "the float stays on page one");
let at = anchor + wordcraft_doc::para::OBJ.len_utf8();
let ref_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= at));
assert_eq!(ref_page, Some(1), "the reference is pushed on to page two");
let notes: Vec<usize> = l
.pages
.iter()
.map(|pg| pg.items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(p), .. } if *p == short)).count())
.collect();
assert_eq!(notes[..2], [0, 1], "the note is on its reference's page, once");
}
/// #4250: of two floats held back for page two, the one whose anchor comes back to page one stays
/// there; the other is still drawn on page two.
#[test]
fn of_two_floats_held_back_only_the_one_whose_anchor_comes_back_stays_on_page_one() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
note.props.space_after = Some(0.0);
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
let noteref = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
d.insert_object(&Pos::body(0, 4), noteref, &Default::default()).unwrap();
let lines = lines_on(&lay(&d), 1);
let f = |y| wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, y) };
d.insert_object(&Pos::body(0, lines[3].2), rect_shape(100.0, 50.0, f(400.0)), &Default::default()).unwrap();
d.insert_object(&Pos::body(0, lines[0].2), rect_shape(100.0, 50.0, f(300.0)), &Default::default()).unwrap();
let l = lay(&d);
let ys = |pi: usize| shapes(&l.pages[pi].items).iter().map(|r| r.y).collect::<Vec<_>>();
assert_eq!((ys(0).len(), ys(1).len()), (1, 1), "one on each page: {:?} {:?}", ys(0), ys(1));
}
/// #4250: a footnote referenced on page two leaves less room there than its lines alone suggest;
/// a float anchored on the first line it pushes on to page three is drawn on page three.
#[test]
fn a_float_anchored_on_a_line_a_footnote_pushes_on_is_drawn_on_the_next_page() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
note.props.space_after = Some(0.0);
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
let noteref = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
let at = lines_on(&lay(&d), 1)[1].2;
d.insert_object(&Pos::body(0, at), noteref, &Default::default()).unwrap();
// A word into the line: an anchor at its very start goes with the line before.
let anchor = lines_on(&lay(&d), 2)[0].2 + "The ".len();
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
let anchor_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= anchor));
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
assert_eq!(anchor_page, Some(2), "the anchor is on page three");
assert_eq!(&per_page[..3], [0, 0, 1], "{per_page:?}");
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left < 1.0), "page two doesn't wrap around it: {beside:?}");
}
/// #4250: widow control takes the line before a paragraph's last on to the next page with it; a
/// float anchored there is drawn on that page.
#[test]
fn a_float_anchored_on_a_line_widow_control_takes_on_is_drawn_on_the_next_page() {
// A paragraph of three pages whose last two lines are on the third only by widow control.
let (mut d, l) = (150..300)
.map(|k| Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(k)))
.map(|d| {
let l = lay(&d);
(d, l)
})
.find(|(_, l)| l.pages.len() == 3 && lines_on(l, 2).len() == 2 && lines_on(l, 1).len() < lines_on(l, 0).len())
.expect("a paragraph ending in a widow");
let anchor = lines_on(&l, 2)[0].2 + "The ".len();
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, page_float(72.0, 300.0)), &Default::default()).unwrap();
let per_page: Vec<usize> = lay(&d).pages.iter().map(|p| shapes(&p.items).len()).collect();
assert_eq!(per_page, [0, 0, 1]);
}
/// #4250: a float whose own wrap pushes its anchor line on to the next page stays on the page
/// whose lines it was placed with, as before, with the text there wrapping around it.
#[test]
fn a_float_whose_wrap_pushes_its_own_line_on_stays_with_the_text_around_it() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
let last = *lines_on(&lay(&d), 1).last().unwrap();
let anchor = last.2 + "The ".len();
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(200.0, 100.0, f), &Default::default()).unwrap();
let l = lay(&d);
let anchor_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= anchor));
assert_eq!(anchor_page, Some(2), "its wrap pushes the anchor on to page three");
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
assert_eq!(&per_page[..3], [0, 1, 0], "{per_page:?}");
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 400.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 200.0), "the text wraps around it: {beside:?}");
}
/// #4250: holding back a float for a later page lays its paragraph out again; its automatic
/// equation numbers aren't counted twice: they read (1), (2).
#[test]
fn a_paragraph_with_a_float_for_a_later_page_keeps_its_equation_numbers() {
let eq = |s: &str| InlineObject::Equation {
linear: s.into(),
display: true,
math: wordcraft_doc::math::Math { nodes: wordcraft_doc::math::parse_linear(s), ..Default::default() },
};
let mut d = Document::from_text(&format!("{}\n", "The quick brown fox jumps over the lazy dog. ".repeat(400)));
let anchor = lines_on(&lay(&d), 1)[5].2;
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let n = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().len();
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_object(n, eq("a=b#"), &Default::default()).unwrap();
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().insert_object(0, eq("c=d#"), &Default::default()).unwrap();
let l = lay(&d);
assert_eq!(shapes(&l.pages[1].items).len(), 1, "held back for page two");
assert_eq!(equation_numbers(&l), ["(1)", "(2)"]);
}
/// #4250: in two columns, a float anchored where the paragraph continues in the second column of
/// the same page is placed with its first piece, as before, so the first column wraps around it.
#[test]
fn a_float_anchored_in_the_next_column_of_the_page_stays_with_its_first_piece() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(120));
d.last_section.columns.count = 2;
let plain = lay(&d);
let lines = lines_on(&plain, 0);
let second = lines.iter().position(|w| w.0 < lines[0].0 + 0.5 && w.2 > 0).expect("a second column on page one");
let anchor = lines[second + 2].2;
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 200.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert_eq!(shapes(&l.pages[0].items).len(), 1, "on page one");
let beside: Vec<_> = lines_on(&l, 0).into_iter().filter(|(y, left, start)| *y > 200.0 && *y < 250.0 && *start < anchor && *left >= 0.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the first column wraps around it: {beside:?}");
}
/// Floating objects are drawn in their stacking order (`relativeHeight`), not in the order of
/// their anchors: a panel anchored after the text box it frames, but lower in the stack, is
/// drawn first so the box stays visible over it. Objects without an order keep theirs.
+14
View File
@@ -324,6 +324,20 @@ fn floating_box_that_resizes_to_fit_takes_its_texts_height() {
assert!((shrunk.h - grown.h).abs() < 0.5, "{shrunk:?} {grown:?}");
}
/// #4250: on a later page of its paragraph, a floating box that resizes to fit its text takes its
/// text's height too.
#[test]
fn a_floating_box_on_a_later_page_of_its_paragraph_fits_its_text() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..Default::default() };
let id = text_box_with(&mut d, 0, "Inside", 144.0, 1.0, TextBody { auto_fit: true, ..Default::default() }, float);
let l = lay(&d);
assert!(l.pages.len() >= 3, "pages {}", l.pages.len());
let hit = l.find_object(0, |o| o.text_box == Some(id)).unwrap();
assert!(hit.page > 0, "drawn on the page of its anchor");
assert!(hit.rect.h > 15.0 && hit.rect.h < 30.0, "one line and the margins: {:?}", hit.rect);
}
#[test]
fn a_turned_linked_box_turns_only_its_own_lines() {
// Linked boxes show the first box's story; turning the second box turns the lines it shows,
@@ -208,7 +208,13 @@ fn language_dialog_default_survives_a_restart() {
use egui_kittest::kittest::Queryable;
let mut a = app();
a.ui.language = "en".into();
a.dialog = Some(Dialog::Layout(Box::new(LayoutDialog::Language(LanguageForm { lang: "fr-FR".into(), no_proof: false, detect: false }))));
a.dialog = Some(Dialog::Layout(Box::new(LayoutDialog::Language(LanguageForm {
lang: "fr-FR".into(),
no_proof: false,
detect: false,
whole_document: false,
initial: ("fr-FR".into(), false),
}))));
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
app.logic(&ui.ctx().clone());
@@ -232,6 +238,96 @@ fn language_dialog_default_survives_a_restart() {
assert_eq!(LanguageForm::read(&b).lang, "fr-FR");
}
/// #3518: ticking "Apply to the whole document" makes OK send `scope: "document"`; unticked sends none.
#[test]
fn language_dialog_whole_document_checkbox_sends_scope() {
use egui_kittest::kittest::Queryable;
let f = LanguageForm { lang: "fr-FR".into(), no_proof: false, detect: false, whole_document: false, initial: ("fr-FR".into(), false) };
assert!(f.params().get("scope").is_none());
assert_eq!(LanguageForm { whole_document: true, ..f.clone() }.params()["scope"], "document");
let mut a = app();
a.ui.language = "en".into();
let para = wordcraft_doc::Paragraph::with_text("Header", Default::default());
let header = a.session.doc.add_part(wordcraft_doc::PartKind::Header, vec![Arc::new(wordcraft_doc::Block::Para(para))]);
a.dialog = Some(Dialog::Layout(Box::new(LayoutDialog::Language(f))));
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
a,
);
h.run_steps(3);
h.get_by_label("Apply to the whole document").click();
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(3);
// The header, which the caret isn't in, took the language.
let doc = &h.state().session.doc;
let hp = doc.para(wordcraft_doc::StoryRef::Part(header), &wordcraft_doc::Path::top(0)).unwrap();
assert_eq!(hp.runs.first().and_then(|r| r.props.lang.as_deref()), Some("fr-FR"));
}
/// "Plain text, then a Do-not-check run", the caret in the plain text.
fn proofing_mix_app() -> WordApp {
let mut a = app();
a.run("text.insert", json!({"text": "Plain code"})).unwrap();
a.run("select.text", json!({"text": "code"})).unwrap();
a.run("review.language", json!({"lang": "de-DE", "noProof": true})).unwrap();
a.run("select.text", json!({"text": "Plain"})).unwrap();
a.run("review.language", json!({"lang": "en-US", "noProof": false})).unwrap();
a.run("select.text", json!({"text": "Plain"})).unwrap();
a
}
fn run_proofing(a: &mut WordApp, text: &str) -> (Option<String>, Option<bool>) {
a.run("select.text", json!({"text": text})).unwrap();
let st = a.run("format.state", json!({})).unwrap();
(st["lang"].as_str().map(String::from), st["noProof"].as_bool())
}
/// #3518: whole-document OK sends only what the user changed.
#[test]
fn language_dialog_whole_document_sends_only_changes() {
// Changing only the language leaves the Do-not-check run alone.
let mut a = proofing_mix_app();
let mut f = LanguageForm::read(&a);
f.whole_document = true;
f.lang = "en-GB".into();
let p = f.params();
assert!(p.get("noProof").is_none());
a.run("review.language", p).unwrap();
assert_eq!(run_proofing(&mut a, "code"), (Some("en-GB".into()), Some(true)));
assert_eq!(run_proofing(&mut a, "Plain"), (Some("en-GB".into()), Some(false)));
// Changing only Do-not-check leaves each run's language alone.
let mut a = proofing_mix_app();
let mut f = LanguageForm::read(&a);
f.whole_document = true;
f.no_proof = true;
let p = f.params();
assert!(p.get("lang").is_none());
a.run("review.language", p).unwrap();
assert_eq!(run_proofing(&mut a, "code"), (Some("de-DE".into()), Some(true)));
assert_eq!(run_proofing(&mut a, "Plain"), (Some("en-US".into()), Some(true)));
// No edits: the caret's language, no noProof (never the engine's en-US default).
let mut a = proofing_mix_app();
let mut f = LanguageForm::read(&a);
f.lang = "fr-FR".into();
f.initial.0 = "fr-FR".into();
f.whole_document = true;
let p = f.params();
assert_eq!(p["lang"], "fr-FR");
assert!(p.get("noProof").is_none());
a.run("review.language", p).unwrap();
assert_eq!(run_proofing(&mut a, "code"), (Some("fr-FR".into()), Some(true)));
// Selection mode is unchanged: both are sent.
f.whole_document = false;
assert_eq!(f.params()["noProof"], false);
}
/// #407: Envelopes starts from the selected address; Add to Document puts an envelope
/// section before the letter without asking to save (nothing is replaced).
#[test]
@@ -320,6 +320,11 @@ pub struct LanguageForm {
pub lang: String,
pub no_proof: bool,
pub detect: bool,
/// "Apply to the whole document": OK sends `scope: "document"` (every story), not the selection.
pub whole_document: bool,
/// The language and Do-not-check state the dialog opened with, to tell what the user changed.
#[serde(skip)]
pub initial: (String, bool),
}
impl LanguageForm {
@@ -328,12 +333,27 @@ impl LanguageForm {
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref());
let here = s.doc.styles.resolve_char(style, &s.typing_props());
let lang = here.lang.unwrap_or_else(|| "en-US".into());
LanguageForm { lang, no_proof: here.no_proof, detect: s.prefs.detect_language }
LanguageForm { initial: (lang.clone(), here.no_proof), lang, no_proof: here.no_proof, detect: s.prefs.detect_language, whole_document: false }
}
/// `review.language` parameters for OK.
pub fn params(&self) -> Value {
json!({"lang": self.lang, "noProof": self.no_proof, "detect": self.detect})
if !self.whole_document {
return json!({"lang": self.lang, "noProof": self.no_proof, "detect": self.detect});
}
// Whole document: the caret's values must not flatten what differs elsewhere, so send only
// what was changed. With no edits at all the language goes (never the engine's en-US default).
let (lang_changed, proof_changed) = (self.lang != self.initial.0, self.no_proof != self.initial.1);
let mut v = json!({"scope": "document", "detect": self.detect});
if let Some(m) = v.as_object_mut() {
if lang_changed || !proof_changed {
m.insert("lang".into(), json!(self.lang));
}
if proof_changed {
m.insert("noProof".into(), json!(self.no_proof));
}
}
v
}
}
@@ -343,7 +363,7 @@ pub(super) fn language_name(tag: &str) -> String {
}
pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm) -> bool {
ui.label(tl!("Mark selected text as:"));
ui.label(if f.whole_document { tl!("Mark the whole document as:") } else { tl!("Mark selected text as:") });
let list = crate::ribbon::language_list(&f.lang);
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, crate::theme::Tokens::get(ui.ctx()).border_strong)).inner_margin(4.0).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(220.0).min_scrolled_height(220.0).show(ui, |ui| {
@@ -360,6 +380,7 @@ pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm)
ui.add_space(4.0);
ui.checkbox(&mut f.no_proof, tl!("Do not check spelling or grammar"));
ui.checkbox(&mut f.detect, tl!("Detect language automatically"));
ui.checkbox(&mut f.whole_document, tl!("Apply to the whole document"));
ui.add_space(4.0);
let set_default = ui.button(tl!("Set As Default")).clicked();
if set_default && run(app, "review.language", json!({"lang": f.lang, "default": true, "detect": f.detect})) {
+172 -20
View File
@@ -9,8 +9,8 @@
//! was on is drawn again without it (see [`dragged_off_page`]), so it is not left behind where it
//! was. The drop is one command, one relayout.
//!
//! Dragging an In Line with Text object moves it within the text, as in Word: a dashed insertion
//! cursor follows the pointer through the text (#2233) and the drop puts it there, still inline
//! Dragging an In Line with Text object moves it within the text, as in Word: a bold insertion
//! cursor follows the pointer through the text (#2233, #4195) and the drop puts it there, still inline
//! (`arrange.bounds` / `arrange.copyTo` with `to`, #2234). A floating one goes where it's dropped.
//!
//! Moving a floating one snaps it to alignment guides ([`crate::guides`]): the page's edges and
@@ -19,6 +19,11 @@
//!
//! A selected connector shows a handle at each end instead of a frame: dragging one onto a shape
//! glues it to the nearest connection site there (`shape.connect`), elsewhere leaves it free.
//!
//! Inside a Drawing Canvas, a press on a member picks that member (`select.member`, #2637): it
//! gets the frame and handles, inside the canvas's frame, and is dragged, resized and turned on
//! its own; a press in a text box member's text puts the caret there. The canvas itself is
//! picked where no member is.
use egui::{Color32, Painter, Pos2, Rect, Ui};
use serde_json::json;
@@ -61,7 +66,7 @@ pub struct ObjectDrag {
/// The object drawn alone over its rotated bounds, for the preview: made once it moves.
preview: Option<egui::TextureHandle>,
/// Moving an In Line with Text object: the text position under the pointer, where it lands,
/// and the colour of the dashed insertion cursor drawn there.
/// and the colour of the bold insertion cursor drawn there.
drop_at: Option<(Pos, Color32)>,
}
@@ -128,22 +133,92 @@ fn active_story(app: &WordApp, layout: &DocLayout) -> StoryRef {
/// model for can be selected and deleted, not moved or resized, so it gets no handles, no move
/// cursor and no drag.
fn fixed(app: &WordApp, hit: &ObjectHit) -> bool {
let pos = hit.pos();
matches!(app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)), Some(InlineObject::Graphic { .. })) && !size_only(app, hit)
matches!(hit_object(app, hit), Some(InlineObject::Graphic { .. })) && !size_only(app, hit)
}
/// Whether the object under `hit` is a chart or SmartArt that WordCraft can edit: it takes
/// resize handles and is laid out again at the new size, but it can't be moved or turned.
fn size_only(app: &WordApp, hit: &ObjectHit) -> bool {
let pos = hit.pos();
matches!(
app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)),
hit_object(app, hit),
Some(InlineObject::Graphic { graphic, .. }) if graphic.chart.is_some() || graphic.smart_art.is_some()
)
}
/// The story of the selected text box.
/// The object a hit is: the object at its position, or the canvas member it names.
fn hit_object<'a>(app: &'a WordApp, hit: &ObjectHit) -> Option<&'a InlineObject> {
let pos = hit.pos();
let o = app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off))?;
match (hit.member, o) {
(None, _) => Some(o),
(Some(i), InlineObject::Group { children, canvas: Some(_), .. }) => children.get(i).map(|c| &c.obj),
_ => None,
}
}
/// Member `index` of the canvas laid out at `canvas`, as a hit of its own: its frame where the
/// canvas draws it, its own turn. `None` when it isn't drawn, or the canvas is turned (its
/// members aren't picked on their own yet).
fn member_hit(app: &WordApp, canvas: &ObjectHit, index: usize) -> Option<ObjectHit> {
let (o, r) = (pickable_canvas(app, canvas)?, canvas.rect);
let (_, frame) = o.member_rects(r.x, r.y, r.w, r.h).into_iter().find(|(i, _)| *i == index)?;
member_with_frame(app, canvas, o, index, frame)
}
/// The canvas object of hit `canvas` when its members can be picked: a canvas, hit as a whole,
/// not turned.
fn pickable_canvas<'a>(app: &'a WordApp, canvas: &ObjectHit) -> Option<&'a InlineObject> {
let pos = canvas.pos();
let o = app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).filter(|o| o.is_canvas())?;
(canvas.member.is_none() && canvas.spin.is_identity()).then_some(o)
}
/// [`member_hit`] for member `index` of canvas `o`, whose frame on the page (`[x, y, w, h]`) is
/// already worked out.
fn member_with_frame(app: &WordApp, canvas: &ObjectHit, o: &InlineObject, index: usize, [x, y, w, h]: [f32; 4]) -> Option<ObjectHit> {
let InlineObject::Group { children, .. } = o else { return None };
let member = children.get(index).filter(|c| wordcraft_engine::cmd::canvas::pickable(c))?;
let text_box = match &member.obj {
InlineObject::Shape { story: Some(id), .. } if app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox) => Some(*id),
_ => None,
};
Some(ObjectHit {
rect: wordcraft_geom::Rect::new(x, y, w, h),
spin: member.obj.frame().map(|(_, _, f)| f.spin()).unwrap_or_default(),
text_box,
// Placed by the canvas, not the text: dragging any handle may move its top-left.
wrap: Wrap::InFrontOfText,
member: Some(index),
..canvas.clone()
})
}
/// The member of the canvas hit `canvas` under the page point (x, y), topmost first; a text box
/// member is grabbed anywhere on it too (inside, [`in_text`] tells its text from its border).
/// The members' frames are worked out once for all of them.
fn member_at(app: &WordApp, canvas: &ObjectHit, x: f32, y: f32, edge: f32) -> Option<ObjectHit> {
let (o, r) = (pickable_canvas(app, canvas)?, canvas.rect);
// Members are drawn clipped to the canvas: only what shows is grabbed.
if !r.contains(wordcraft_geom::Point::new(x, y)) {
return None;
}
o.member_rects(r.x, r.y, r.w, r.h)
.into_iter()
.rev()
.filter_map(|(i, frame)| member_with_frame(app, canvas, o, i, frame))
.find(|m| m.rect.expand(edge).contains(m.unspin(x, y)))
}
/// Whether the page point (x, y) is in a text box member's text rather than on its border.
fn in_text(m: &ObjectHit, x: f32, y: f32, edge: f32) -> bool {
m.text_box.is_some() && m.rect.w.min(m.rect.h) > edge * 3.0 && m.rect.expand(-edge).contains(m.unspin(x, y))
}
/// The story of the selected text box (a picked canvas member too).
fn selected_text_box(app: &WordApp) -> Option<u32> {
if let Some((_, _, InlineObject::Shape { story: Some(id), .. })) = wordcraft_engine::cmd::canvas::selected_member(&app.session) {
return app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox).then_some(*id);
}
match selected(app)? {
(_, InlineObject::Shape { story: Some(id), .. }) if app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox) => Some(*id),
_ => None,
@@ -154,7 +229,11 @@ fn selected_text_box(app: &WordApp) -> Option<u32> {
pub fn active(app: &WordApp, layout: &DocLayout) -> Option<(ObjectHit, bool)> {
let hint = app.session.page_hint;
if let Some((pos, _)) = selected(app) {
return layout.object(&pos, hint).map(|o| (o, false));
let canvas = layout.object(&pos, hint)?;
if let Some((_, i, _)) = wordcraft_engine::cmd::canvas::selected_member(&app.session) {
return member_hit(app, &canvas, i).or(Some(canvas)).map(|o| (o, false));
}
return Some((canvas, false));
}
match app.session.sel.focus.story {
StoryRef::Part(id) if crate::canvas::in_text_box(app) => layout.text_box(id, hint).map(|o| (o, true)),
@@ -212,7 +291,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
}
if ui.input(|i| i.pointer.primary_down()) {
let shift = ui.input(|i| i.modifiers.shift);
d.copy = ui.input(|i| i.modifiers.command) && d.drag.grab == Grab::Move;
d.copy = ui.input(|i| i.modifiers.command) && d.drag.grab == Grab::Move && d.object.member.is_none();
if d.drag.grab == Grab::Rotate {
d.drag.turn_to(at, pages, scale, shift);
} else {
@@ -230,7 +309,12 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
if d.drag.grab == Grab::Move && d.drag.moved && !d.object.floating() {
// An inline one lands in the text: the insertion cursor follows the pointer.
d.drop_at = drop_point(app, layout, pages, scale, at, &d);
} else if d.drag.grab == Grab::Move && d.drag.moved && app.ui.alignment_guides && !ui.input(|i| i.modifiers.alt) {
} else if d.drag.grab == Grab::Move
&& d.drag.moved
&& d.object.member.is_none()
&& app.ui.alignment_guides
&& !ui.input(|i| i.modifiers.alt)
{
snap_move(layout, &mut d, scale, locked_axis);
}
}
@@ -279,6 +363,17 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
}
// Shift/Ctrl+click adds an object to the selected ones (or takes it out), for Group.
let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
if let Some(index) = object.member.filter(|_| pressed && !adding) {
return press_member(app, layout, pages, scale, at, object, index, grab);
}
// Adding to the selection takes the whole canvas.
let object = match object.member {
Some(_) => match layout.object(&object.pos(), object.page) {
Some(o) => o,
None => return false,
},
None => object,
};
// Ctrl on the object that is already selected starts a copy-drag instead, including
// Ctrl+Shift for an aligned copy. Shift alone keeps its additive-selection behavior.
let again = selected(app).is_some_and(|(p, _)| p == object.pos())
@@ -339,13 +434,55 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
true
}
/// A press on member `index` of a canvas (`object` is the member's hit): in a text box member's
/// text it puts the caret there; elsewhere it picks the member (selecting its canvas first) and
/// starts dragging it, or the handle `grab`.
#[allow(clippy::too_many_arguments)]
fn press_member(app: &mut WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2, object: ObjectHit, index: usize, grab: Grab) -> bool {
let edge = EDGE / scale.max(0.01);
if grab == Grab::Move
&& let (Some(id), Some((page, x, y))) = (object.text_box, crate::canvas::page_at(pages, layout, scale, at))
&& in_text(&object, x, y, edge)
{
// A box linked from another shows (and edits) that box's story.
let id = app.session.doc.text_box_chain(id).and_then(|c| c.first().copied()).unwrap_or(id);
if let Some(pos) = layout.hit(page, x, y, StoryRef::Part(id)) {
let _ = app.run("caret.set", json!({"pos": pos}));
}
return true;
}
let pos = object.pos();
let end = Pos { off: pos.off + OBJ.len_utf8(), ..pos.clone() };
let _ = app.run("select.range", json!({"anchor": pos, "focus": end}));
if app.run("select.member", json!({"index": index})).is_err() {
return true;
}
let Some(o) = hit_object(app, &object) else { return true };
let (min, picture) = (wordcraft_engine::cmd::objects::min_size(o), matches!(o, InlineObject::Image { .. }));
app.canvas.obj_drag = Some(ObjectDrag {
drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at),
press_origin: pages.get(object.page).map_or(at, |p| p.min),
object,
min,
picture,
pending_behind: false,
end: None,
guides: Vec::new(),
preview: None,
copy: false,
drop_at: None,
});
true
}
/// The object a move has taken off its page (which page, and where it is in the document): the
/// page is rendered again without it while the preview follows the pointer, so the object is not
/// left behind where it was. Only for a move — resizing and turning show the object itself, and
/// dragging a connector's end onto a shape still shows that connector.
/// dragging a connector's end onto a shape still shows that connector. Nor for a member of a
/// drawing canvas: its position is the canvas's, and the canvas stays.
pub(crate) fn dragged_off_page(drag: Option<&ObjectDrag>) -> Option<(usize, Pos)> {
let d = drag?;
if !d.drag.moved || d.end.is_some() || d.drag.grab != Grab::Move {
if !d.drag.moved || d.end.is_some() || d.drag.grab != Grab::Move || d.object.member.is_some() {
return None;
}
Some((d.object.page, d.object.pos()))
@@ -480,15 +617,18 @@ fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Po
return selected_picture;
}
// Selectable: body objects (table cells too) or the header/footer's own objects;
// notes' objects aren't yet.
layout.object_at_in(page, x, y, EDGE / scale.max(0.01), story).map(|o| (o, Grab::Move))
// notes' objects aren't yet. Inside a canvas, the member there.
let edge = EDGE / scale.max(0.01);
let o = layout.object_at_in(page, x, y, edge, story)?;
Some((member_at(app, &o, x, y, edge).unwrap_or(o), Grab::Move))
})
}
/// Objects that can be dragged to a new place: those in the own paragraphs of the body or of a
/// header/footer.
fn movable(app: &WordApp, o: &ObjectHit) -> bool {
o.path.depth() == 0 && selectable_story(app, o.story)
// A canvas member moves inside its canvas, wherever the canvas is.
(o.member.is_some() || o.path.depth() == 0) && selectable_story(app, o.story)
}
/// Whether a Behind Text object of this kind is grabbed by pressing its body. Pictures, shapes and
@@ -541,7 +681,7 @@ fn preview_scale(px: f32, w: f32, h: f32, max_side: usize) -> f32 {
/// `o` drawn alone over its rotated bounds, as a texture, at the canvas's `scale` (screen points
/// per page point).
fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit, scale: f32) -> Option<egui::TextureHandle> {
let obj = app.session.doc.para_at(&o.pos()).and_then(|p| p.object_at(o.off))?;
let obj = hit_object(app, o)?;
let page = layout.pages.get(o.page)?;
let mut opts = crate::canvas::screen_render_options();
opts.dark = crate::canvas::dark_page(app);
@@ -668,6 +808,13 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
if let Some((o, editing)) = active(app, layout)
&& let Some(f) = screen(pages, scale, o.page, o.rect)
{
// A picked canvas member: the canvas's frame around it, without handles.
if o.member.is_some()
&& let Some(c) = layout.object(&o.pos(), o.page)
&& let Some(cf) = screen(pages, scale, c.page, c.rect)
{
frame::paint(painter, t, cf, c.spin.deg, false, false, false);
}
// Handles where it can be resized and turned (body and header/footer objects,
// not charts): else a plain frame.
let handles = selectable_story(app, o.story) && !fixed(app, &o);
@@ -675,12 +822,13 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
}
}
/// The dashed insertion cursor where a dragged In Line with Text object will land.
/// The insertion cursor where a dragged In Line with Text object will land: solid and twice as
/// wide as the caret, so it stands out as Word's does (#4195).
fn paint_drop_caret(d: &ObjectDrag, painter: &Painter, layout: &DocLayout, pages: &[Rect], scale: f32) {
let Some((pos, color)) = &d.drop_at else { return };
let Some(c) = layout.caret_on(pos, d.drag.to_page) else { return };
let Some(r) = screen(pages, scale, c.page, wordcraft_geom::Rect::new(c.x, c.top, 0.0, c.height)) else { return };
painter.extend(egui::Shape::dashed_line(&[r.left_top(), r.left_bottom()], egui::Stroke::new(1.5, *color), 2.0, 2.0));
painter.line_segment([r.left_top(), r.left_bottom()], egui::Stroke::new(3.0, *color));
}
/// A connector's end being dragged: a line from its other end to the pointer, and the connection
@@ -740,7 +888,11 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at:
if connector_end_at(app, layout, pages, scale, at).is_some() {
return Some(egui::CursorIcon::Crosshair);
}
grab_at(app, layout, pages, scale, at).map(|(o, g)| hover_cursor(app, &o, g))
let in_member_text = |o: &ObjectHit| {
crate::canvas::page_at(pages, layout, scale, at).is_some_and(|(_, x, y)| o.member.is_some() && in_text(o, x, y, EDGE / scale.max(0.01)))
};
grab_at(app, layout, pages, scale, at)
.map(|(o, g)| if g == Grab::Move && !fixed(app, &o) && in_member_text(&o) { egui::CursorIcon::Text } else { hover_cursor(app, &o, g) })
}
/// The cursor over part `g` of object `o`: none of a drag for an object that won't move there (a
+95 -1
View File
@@ -500,7 +500,7 @@ fn alt_pressed_during_a_drag_unsnaps_and_keeps_keytips_off() {
assert_eq!(h.state().ui.keytips, crate::keytips::Phase::Off, "Alt during a drag isn't a keytip tap");
}
/// #2233, #2234: dragging an In Line with Text picture into another paragraph shows a dashed
/// #2233, #2234: dragging an In Line with Text picture into another paragraph shows a bold
/// insertion cursor at the text under the pointer (no alignment guides), and the drop puts the
/// picture there, still inline and selected. Dropped back where it was, nothing changes.
#[test]
@@ -551,6 +551,13 @@ fn an_inline_picture_drags_to_the_insertion_cursor_and_stays_inline() {
let d = h.state().canvas.obj_drag.as_ref().expect("dragging");
assert_eq!(d.drop_at.as_ref().map(|(p, _)| p.clone()), Some(target.clone()), "the insertion cursor is at the text under the pointer");
assert!(d.guides.is_empty(), "an inline object doesn't snap to guides");
// #4195: solid and twice the caret's width, so it can be seen, as in Word.
let (top, bottom) = (at(&mut h, c.x, c.top), at(&mut h, c.x, c.top + c.height));
let bold = h.output().shapes.iter().any(|s| {
matches!(&s.shape, egui::Shape::LineSegment { points: [a, b], stroke }
if a.distance(top) < 0.5 && b.distance(bottom) < 0.5 && stroke.width == 3.0)
});
assert!(bold, "a bold insertion cursor from the line's top to its bottom");
button(&h, end, false);
steps(&mut h, 3);
assert!(h.state().canvas.obj_drag.is_none());
@@ -1010,3 +1017,90 @@ fn dragging_a_large_picture_renders_a_preview_within_the_gpu_texture_limit() {
let [width, height] = preview.size();
assert!(width <= max_side && height <= max_side, "preview is {width}×{height}, texture limit is {max_side}");
}
/// #2637: inside a Drawing Canvas the mouse works on the members, as in Word. A click on a
/// shape picks that shape (the canvas stays the selection in the text) and shows its frame;
/// dragging it moves it inside the canvas; a click in a text box member's text puts the
/// caret there. Before, the canvas took every click, as one static object.
#[test]
fn clicking_and_dragging_inside_a_canvas_works_on_its_members() {
use egui::{Event, PointerButton};
use wordcraft_engine::cmd::canvas::selected_member;
let mut a = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("insert.canvas", json!({"width": 400, "height": 300})).unwrap();
a.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
a.run("canvas.member", json!({"index": 0, "x": 40, "y": 30})).unwrap();
a.run("insert.textBox", json!({"text": "Label"})).unwrap();
a.run("select.objects", json!({"index": 0})).unwrap();
a.run("canvas.member", json!({"index": 1, "x": 200, "y": 100, "width": 150, "height": 80})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
let frame = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).expect("the canvas is laid out").rect;
let at = |h: &mut egui_kittest::Harness<'_, WordApp>, x: f32, y: f32| {
crate::canvas::page_to_screen(h.state_mut(), 0, frame.x + x, frame.y + y).unwrap()
};
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let members =
|h: &egui_kittest::Harness<'_, WordApp>| match h.state().session.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()) {
Some(InlineObject::Group { children, .. }) => children.iter().map(|c| (c.x, c.y)).collect::<Vec<_>>(),
o => panic!("{o:?}"),
};
// A click on the rectangle picks it.
let on_rect = at(&mut h, 90.0, 50.0);
h.event(Event::PointerMoved(on_rect));
h.event(button(on_rect, true));
h.step();
h.event(button(on_rect, false));
for _ in 0..3 {
h.step();
}
assert_eq!(selected_member(&h.state().session).map(|(_, i, _)| i), Some(0), "the rectangle is picked, not the whole canvas");
let shown = {
let app = h.state_mut();
let layout = app.session.layout();
active(app, &layout).map(|(o, _)| (o.member, o.rect))
};
assert_eq!(shown, Some((Some(0), wordcraft_geom::Rect::new(frame.x + 40.0, frame.y + 30.0, 100.0, 40.0))), "its frame is the one shown");
// Dragged 50 pt right and 20 pt down, it moves inside the canvas; the canvas stays put.
let to = at(&mut h, 140.0, 70.0);
h.event(Event::PointerMoved(on_rect));
h.event(button(on_rect, true));
h.step();
for p in [on_rect + egui::vec2(0.0, 30.0), to + egui::vec2(0.0, 30.0), to] {
h.event(Event::PointerMoved(p));
h.step();
}
h.event(button(to, false));
for _ in 0..3 {
h.step();
}
let moved = members(&h);
let near = |(x, y): (f32, f32), (ex, ey): (f32, f32)| (x - ex).abs() < 0.01 && (y - ey).abs() < 0.01;
assert!(near(moved[0], (90.0, 50.0)) && near(moved[1], (200.0, 100.0)), "{moved:?}");
assert_eq!(h.state_mut().session.layout().object(&Pos::body(0, 0), 0).map(|o| o.rect), Some(frame));
// A click in the text box's text puts the caret in it.
let in_box = at(&mut h, 275.0, 140.0);
h.event(Event::PointerMoved(in_box));
h.event(button(in_box, true));
h.step();
h.event(button(in_box, false));
for _ in 0..3 {
h.step();
}
let story = h.state().session.sel.focus.story;
let is_box = matches!(story, StoryRef::Part(id) if h.state().session.doc.parts.get(&id).is_some_and(|p| p.kind == PartKind::TextBox));
assert!(is_box, "the caret is in the text box's text: {story:?}");
}
+10 -3
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11 -3
View File
File diff suppressed because one or more lines are too long
@@ -126,6 +126,6 @@ modules:
# the two equal).
- type: git
url: https://github.com/storytold/craft-i18n
commit: 9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
commit: 1e515422afdda2f317af430c1f9d68a5f83935b4
dest: craft-i18n
- cargo-sources.json