Files
wordcraft/crates/layout/src/tests_textbox.rs
T
2026-10-18 11:52:32 -04:00

213 lines
10 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);
}
#[test]
fn inline_box_that_resizes_to_fit_takes_its_texts_height() {
// #2791: an inline box with Resize shape to fit text, saved 10 cm tall around three short
// lines, shrinks to them as in Word; the page break after it stays on the first page.
let brk = wordcraft_doc::para::PAGE_BREAK;
let doc = |auto_fit: bool| {
let filler: Vec<String> = (1..=FILLER).map(|i| format!("Filler line {i}")).collect();
let mut d = Document::from_text(&format!("{}\n\n\n{brk}\nSecond page text.", filler.join("\n")));
let id = text_box(&mut d, FILLER, "", 453.5, 283.46, TextBody { auto_fit, anchor: VAlign::Center, ..Default::default() });
d.parts.get_mut(&id).unwrap().blocks =
(1..=3).map(|i| para_block(Paragraph::with_text(&format!("Text box line {i}"), Default::default()))).collect();
let l = lay(&d);
let r = l.pages.iter().find_map(|p| {
p.items.iter().find_map(|it| match it {
Placed::Object { rect, text_box: Some(s), .. } if *s == id => Some(*rect),
_ => None,
})
});
(l.pages.len(), r.unwrap())
};
let (pages, r) = doc(true);
assert!(r.h > 3.0 * 12.0 && r.h < 80.0, "the box fits its three lines: {r:?}");
assert_eq!(pages, 2, "the page break stays on the first page");
let (pages, saved) = doc(false);
assert_eq!(pages, 3, "a box keeping its 10 cm pushes the page break over");
assert!((saved.h - 283.46).abs() < 1.0, "without auto-fit the box keeps its saved height: {saved:?}");
}
/// Filler paragraphs before the box in [`inline_box_that_resizes_to_fit_takes_its_texts_height`]:
/// enough that a 10 cm box no longer leaves room for the page break on the first page.
const FILLER: usize = 22;