mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-13 05:52:42 +00:00
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>
181 lines
8.3 KiB
Rust
181 lines
8.3 KiB
Rust
//! 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);
|
||
}
|