Files
wordcraft/crates/layout/src/tests_textbox.rs
T
45abb5ded3 #743: freeforms in groups keep their shape; text boxes keep their margins and Resize to fit (#843)
A Word 365 cover page lost its look. The missing fills came from
theme-coloured shapes (scheme colours and wps:style fill references),
which the reporter's 0.5.0 build didn't read; #611 fixed that on main.
Two causes were still open on main:

- A freeform (a:custGeom) inside a wpg group was drawn as a rectangle.
  read_wsp now reads custom geometry, so group members get it as well.
- Text boxes ignored wps:bodyPr lIns/tIns/rIns/bIns and a:spAutoFit.
  Layout always used Word's default margins, so a title box with no
  margins that was just tall enough for its text lost its second line.
  TextBody now carries the box's own margins and auto_fit. Layout uses
  those margins and doesn't cut the text of a box that resizes to fit.
  Both are written back on save.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:37:13 -04:00

181 lines
8.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Text boxes' text direction, alignment and linked boxes (Shape Format › Text).
use super::*;
use wordcraft_doc::props::{TextVert, VAlign};
use wordcraft_doc::{PartKind, Pos, TextBody, para_block};
fn lay(doc: &Document) -> DocLayout {
let mut c = LayoutCache::new();
layout(doc, &mut c, &LayoutOptions::default())
}
/// A text box `w` × `h` showing `text`, inline at the end of body paragraph `para`; its story id.
fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32, body: TextBody) -> u32 {
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, Default::default()))]);
if let Some(p) = d.parts.get_mut(&id) {
p.body = body;
}
let shape = InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::TextBox,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float: Default::default(),
story: Some(id),
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
let off = d.para(StoryRef::Body, &Path::top(para)).map_or(0, |p| p.len());
d.insert_object(&Pos::body(para, off), shape, &Default::default()).unwrap();
id
}
/// The lines of story `id` on the first page: (x, y, turn, l0, l1).
fn lines(l: &DocLayout, id: u32) -> Vec<(f32, f32, TextDirection, usize, usize)> {
l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Part(s), x, y, turn, l0, l1, .. } if *s == id => Some((*x, *y, *turn, *l0, *l1)),
_ => None,
})
.collect()
}
fn frame_of(l: &DocLayout, id: u32) -> Rect {
l.find_object(0, |o| o.text_box == Some(id)).unwrap().rect
}
#[test]
fn align_text_and_text_direction_place_the_text_in_the_box() {
let at = |body: TextBody| {
let mut d = Document::from_text("x");
let id = text_box(&mut d, 0, "Hi", 144.0, 144.0, body);
let l = lay(&d);
(lines(&l, id)[0], frame_of(&l, id))
};
let ((_, top, turn, ..), r) = at(TextBody::default());
assert_eq!(turn, TextDirection::Horizontal);
assert!((top - (r.y + BOX_INSET_Y)).abs() < 1.0, "top-aligned text starts under the top inset: {top} in {r:?}");
let ((_, mid, ..), _) = at(TextBody { anchor: VAlign::Center, ..Default::default() });
let ((_, bottom, ..), _) = at(TextBody { anchor: VAlign::Bottom, ..Default::default() });
assert!(mid > top + 40.0 && bottom > mid + 40.0, "{top} {mid} {bottom}");
assert!(bottom < r.bottom() - BOX_INSET_Y, "the line stays inside the box");
// Rotated 90°: lines run down from the top right corner; 270°: up from the bottom left.
let ((x, y, turn, ..), r) = at(TextBody { vert: TextVert::Vert, ..Default::default() });
assert_eq!(turn, TextDirection::Down);
assert!((x - (r.right() - BOX_INSET_X)).abs() < 1.0 && (y - (r.y + BOX_INSET_Y)).abs() < 1.0, "{x},{y} in {r:?}");
let ((x, y, turn, ..), r) = at(TextBody { vert: TextVert::Vert270, anchor: VAlign::Bottom, ..Default::default() });
assert_eq!(turn, TextDirection::Up);
assert!(x > r.x + 100.0 && (y - (r.bottom() - BOX_INSET_Y)).abs() < 1.0, "bottom-aligned turned text sits at the far side: {x},{y} in {r:?}");
// Stacked: one letter to a line, upright.
let mut d = Document::from_text("x");
let id = text_box(&mut d, 0, "Hi", 144.0, 144.0, TextBody { vert: TextVert::Stacked, ..Default::default() });
let l = lay(&d);
let (.., l0, l1) = lines(&l, id)[0];
assert_eq!(l1 - l0, 2, "two letters, two lines");
}
#[test]
fn linked_boxes_continue_the_text() {
let mut d = Document::from_text("one\ntwo");
let text = "Words that run on well past the first little box. ".repeat(6);
let a = text_box(&mut d, 0, &text, 144.0, 48.0, TextBody::default());
let b = text_box(&mut d, 1, "", 144.0, 400.0, TextBody::default());
let unlinked = lay(&d);
let shown = |l: &DocLayout| lines(l, a).iter().map(|(.., l0, l1)| l1 - l0).sum::<usize>();
let in_a = shown(&unlinked);
d.parts.get_mut(&a).unwrap().body.next = Some(b);
let l = lay(&d);
// Both boxes show (and edit) the first box's story.
let frames: Vec<Rect> = l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Object { rect, text_box: Some(s), .. } if *s == a => Some(*rect),
_ => None,
})
.collect();
assert_eq!(frames.len(), 2);
let rb = frames[1];
let ls = lines(&l, a);
// The first box shows what fits; the second picks up at the next line, at its top.
let (first, rest): (Vec<(f32, f32, TextDirection, usize, usize)>, Vec<_>) = ls.iter().partition(|(_, y, ..)| *y < rb.y);
assert_eq!(first.iter().map(|(.., l0, l1)| l1 - l0).sum::<usize>(), in_a);
let (_, y, _, l0, _) = rest[0];
assert_eq!(l0, in_a, "continues with the first line the first box couldn't show");
assert!((y - (rb.y + BOX_INSET_Y)).abs() < 1.0);
// Every line shows once.
let total = ls.iter().map(|(.., l0, l1)| l1 - l0).sum::<usize>();
let all = l.pages[0].items.iter().find_map(|it| match it {
Placed::Lines { story: StoryRef::Part(s), para, .. } if *s == a => Some(para.lines.len()),
_ => None,
});
assert_eq!(Some(total), all);
}
#[test]
fn hostile_link_chains_stay_bounded() {
// Boxes linking in a circle show their own text; a box linking to itself too.
let mut d = Document::from_text("one\ntwo\nthree");
let a = text_box(&mut d, 0, "A", 72.0, 36.0, TextBody::default());
let b = text_box(&mut d, 1, "B", 72.0, 36.0, TextBody::default());
let c = text_box(&mut d, 2, "C", 72.0, 36.0, TextBody::default());
d.parts.get_mut(&a).unwrap().body.next = Some(b);
d.parts.get_mut(&b).unwrap().body.next = Some(a);
d.parts.get_mut(&c).unwrap().body.next = Some(c);
assert!(d.text_box_chains().is_empty());
let l = lay(&d);
for id in [a, b, c] {
assert_eq!(lines(&l, id).len(), 1, "box {id} shows its own text");
}
}
#[test]
fn own_margins_and_resize_to_fit_keep_the_text_in_view() {
// #743: a cover page's two-line box with no margins (`lIns="0"`…) is just tall enough for
// its text; with Word's default margins its second line would be cut.
let two = |h: f32, body: TextBody| {
let mut d = Document::from_text("x");
let id = text_box(&mut d, 0, "One", 144.0, h, body);
d.parts.get_mut(&id).unwrap().blocks =
vec![para_block(Paragraph::with_text("One", Default::default())), para_block(Paragraph::with_text("Two", Default::default()))];
let l = lay(&d);
let shown: usize = lines(&l, id).iter().map(|(.., l0, l1)| l1 - l0).sum();
(shown, lines(&l, id).first().map(|(x, y, ..)| (*x, *y)), frame_of(&l, id))
};
// How deep the two lines reach without margins, from a box with room to spare.
let zero = TextBody { inset: Some([0; 4]), ..Default::default() };
let mut d = Document::from_text("x");
let id = text_box(&mut d, 0, "One", 144.0, 400.0, zero);
d.parts.get_mut(&id).unwrap().blocks =
vec![para_block(Paragraph::with_text("One", Default::default())), para_block(Paragraph::with_text("Two", Default::default()))];
let l = lay(&d);
let r = frame_of(&l, id);
let need = l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Part(s), para, l0, l1, y, .. } if *s == id => {
let (first, last) = (para.lines.get(*l0)?, para.lines.get(l1.checked_sub(1)?)?);
Some(y + last.top + last.height - first.top - r.y)
}
_ => None,
})
.fold(0.0f32, f32::max);
assert!(need > 10.0, "{need}");
let (shown, at, r) = two(need + 1.0, zero);
assert_eq!(shown, 2, "both lines fit without margins");
let (x, y) = at.unwrap();
assert!((x - r.x).abs() < 0.5 && (y - r.y).abs() < 0.5, "text starts at the box's corner: {x},{y} in {r:?}");
assert_eq!(two(need + 1.0, TextBody::default()).0, 1, "Word's default margins cut the second line");
let (shown, ..) = two(need / 2.0, TextBody { auto_fit: true, ..Default::default() });
assert_eq!(shown, 2, "a box that resizes to fit its text shows it all");
// Hostile margins wider than the box still lay out.
let (shown, ..) = two(20.0, TextBody { inset: Some([u32::MAX; 4]), ..Default::default() });
assert!(shown >= 1);
}