Files
wordcraft/crates/layout/src/tests.rs
T
be7474566b Layout: blank pages put sections on the right side, and paragraph spacing is the larger of after and before (#733, rebased) (#964)
Rebased #733 (paragraph spacing larger of after and before; blank pages for odd/even section starts) onto main.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 11:18:57 -04:00

4896 lines
245 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.
use super::*;
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, ParaProps, TextDirection};
use wordcraft_doc::{Pos, Table};
fn lay(doc: &Document) -> DocLayout {
let mut c = LayoutCache::new();
layout(doc, &mut c, &LayoutOptions::default())
}
fn lines_of(l: &DocLayout) -> usize {
l.pages.iter().flat_map(|p| p.items.iter()).map(|it| if let Placed::Lines { l0, l1, .. } = it { l1 - l0 } else { 0 }).sum()
}
#[test]
fn empty_doc_has_one_page() {
let d = Document::new();
let l = lay(&d);
assert_eq!(l.pages.len(), 1);
assert_eq!(lines_of(&l), 1);
let c = l.caret(&Pos::body(0, 0)).unwrap();
assert_eq!(c.page, 0);
assert!((c.x - 72.0).abs() < 0.5, "{c:?}");
assert!(c.top >= 72.0 && c.height > 8.0);
}
#[test]
fn long_paragraph_wraps_and_paginates() {
let text = "The quick brown fox jumps over the lazy dog. ".repeat(400);
let d = Document::from_text(&text);
let l = lay(&d);
assert!(l.pages.len() >= 3, "pages {}", l.pages.len());
// Every line fits the text width.
for p in &l.pages {
for it in &p.items {
if let Placed::Lines { para, l0, l1, x, .. } = it {
for li in *l0..*l1 {
let line = &para.lines[li];
let w = para.x_of(li, line.stop).unwrap() + x;
assert!(w <= 612.0 - 72.0 + 30.0, "line too wide: {w}");
}
}
}
}
}
#[test]
fn reading_layout_reflows_into_viewport_height_pages_without_changing_print_layout() {
let d = Document::from_text(&"A moderately long sentence that should wrap across reading columns. ".repeat(250));
let opts = LayoutOptions { view: ViewMode::Read, web_width: 230.0, read_height: 390.0, ..Default::default() };
let read = layout(&d, &mut LayoutCache::new(), &opts);
assert!(read.pages.len() > 5, "reading column count: {}", read.pages.len());
assert!(read.pages.iter().all(|p| p.w == 230.0 && p.h == 390.0));
assert!(read.pages.iter().all(|p| p.body.w < p.w && p.body.h < p.h));
assert_eq!(
lines_of(&read),
lines_of(&layout(&d, &mut LayoutCache::new(), &LayoutOptions { view: ViewMode::Web, web_width: 230.0, ..Default::default() }))
);
let print = lay(&d);
assert_eq!(print.pages[0].w, d.sections()[0].1.page_w);
assert_eq!(print.pages[0].h, d.sections()[0].1.page_h);
// Window height changes page breaks, not the text, and a malformed dimension stays bounded.
let short = layout(&d, &mut LayoutCache::new(), &LayoutOptions { read_height: 260.0, ..opts.clone() });
assert!(short.pages.len() > read.pages.len());
let spaced = layout(&d, &mut LayoutCache::new(), &LayoutOptions { read_line_spacing: Some(2.0), ..opts.clone() });
assert!(spaced.pages.len() > read.pages.len(), "reader-only line spacing repaginates text");
let invalid = layout(&d, &mut LayoutCache::new(), &LayoutOptions { web_width: f32::NAN, read_height: f32::INFINITY, ..opts });
assert!(invalid.pages.iter().all(|p| p.w.is_finite() && p.h.is_finite()));
}
#[test]
fn hit_and_caret_agree() {
let d = Document::from_text("Hello world, this is WordCraft.\nSecond paragraph here.");
let l = lay(&d);
for off in [0, 5, 12, 31] {
let pos = Pos::body(0, off);
let c = l.caret(&pos).unwrap();
let back = l.hit(c.page, c.x + 0.1, c.top + c.height / 2.0, StoryRef::Body).unwrap();
assert_eq!(back, pos, "off {off}");
}
let c1 = l.caret(&Pos::body(1, 0)).unwrap();
let (down, _) = l.vertical(&Pos::body(0, 0), None, 1, 0).unwrap();
assert_eq!(down.path, Path::top(1));
assert!(c1.top > l.caret(&Pos::body(0, 0)).unwrap().top);
let (up, _) = l.vertical(&Pos::body(1, 3), None, -1, 0).unwrap();
assert_eq!(up.path, Path::top(0));
}
#[test]
fn selection_bar_finds_the_line_beside_the_pointer() {
// #856: the left margin beside a line is Word's selection bar.
let text = format!("{}\nShort.", "The quick brown fox jumps over the lazy dog. ".repeat(8));
let mut d = Document::from_text(&text);
d.insert_block(StoryRef::Body, &Path::top(2), wordcraft_doc::Block::Table(Table::new(1, 1, 468.0))).unwrap();
let l = lay(&d);
let p = &l.pages[0];
let c = l.caret(&Pos::body(0, 0)).unwrap();
let first = l.selection_bar_at(0, 30.0, c.top + c.height / 2.0).unwrap();
assert_eq!(first, Pos::body(0, 0));
// The second line of the wrapped paragraph: its start, and its span is the whole line.
let line2 = l.selection_bar_at(0, 30.0, c.top + c.height * 1.5).unwrap();
assert_eq!(line2.path, Path::top(0));
assert!(line2.off > 0);
let (a, b, last) = l.line_span(&line2, 0).unwrap();
assert_eq!(a, line2);
assert!(b.off > a.off && !last);
let (_, b, last) = l.line_span(&Pos::body(1, 0), 0).unwrap();
assert_eq!(b.off, "Short.".len());
assert!(last);
// In the text column, the header area, off the page or beside a table: not the bar.
assert_eq!(l.selection_bar_at(0, p.body.x + 5.0, c.top + 2.0), None);
assert_eq!(l.selection_bar_at(0, 30.0, p.body.y - 10.0), None);
assert_eq!(l.selection_bar_at(0, -5.0, c.top + 2.0), None);
assert_eq!(l.selection_bar_at(0, f32::NAN, c.top), None);
assert_eq!(l.selection_bar_at(9, 30.0, c.top), None);
let cell = l.caret(&Pos { story: StoryRef::Body, path: Path(vec![2, 0, 0, 0]), off: 0 }).unwrap();
assert_eq!(l.selection_bar_at(0, 30.0, cell.top + cell.height / 2.0), None);
// Dragging takes the nearest top-level line at any height.
assert_eq!(l.body_line_at(0, -100.0), Some(Pos::body(0, 0)));
}
#[test]
fn deletions_leave_the_final_text_layout() {
// "Keep " + deleted "DELETEDTEXT " (5..17) + inserted "INSERTED".
let mut d = Document::from_text("Keep DELETEDTEXT INSERTED");
d.format_range(&Pos::body(0, 5), &Pos::body(0, 17), &|c| c.del = Some(0)).unwrap();
d.format_range(&Pos::body(0, 17), &Pos::body(0, 25), &|c| c.ins = Some(0)).unwrap();
let texts = |l: &DocLayout, markup: bool| -> (String, usize) {
let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions { markup, ..Default::default() });
let text = items.iter().filter_map(|i| if let display::Draw::Glyphs { text, .. } = i { Some(text.as_str()) } else { None }).collect();
(text, items.iter().filter(|i| matches!(i, display::Draw::Line { .. })).count())
};
// Markup: the deletion is laid out and struck through, the insertion underlined, and the
// changed line has a bar in the margin.
let full = lay(&d);
assert_eq!(texts(&full, true), ("Keep DELETEDTEXT INSERTED".into(), 3));
// Without markup a full layout still never prints the deletion as plain text.
assert!(!texts(&full, false).0.contains("DELETED"));
// The final layout gives the deletion no width.
let fin = layout(&d, &mut LayoutCache::new(), &LayoutOptions { hide_deleted: true, ..Default::default() });
assert_eq!(texts(&fin, false), ("Keep INSERTED".into(), 0));
let at = |off| fin.caret(&Pos::body(0, off)).unwrap();
assert!((at(5).x - at(17).x).abs() < 0.01, "{:?} {:?}", at(5), at(17));
// Every offset maps to a caret, and clicks land outside the hidden text.
for off in 0..=25 {
let c = at(off);
let back = fin.hit(c.page, c.x + 0.1, c.top + c.height / 2.0, StoryRef::Body).unwrap();
assert!(back.off <= 5 || back.off >= 17, "off {off} hit {back:?}");
}
}
#[test]
fn deleted_soft_hyphen_is_no_hyphenation_point_without_markup() {
// "ab\u{ad}cdef" in a column where "ab-" fits but the whole word does not; only the soft
// hyphen is track-deleted.
let mut d = Document::from_text("ab\u{ad}cdef");
d.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| c.del = Some(0)).unwrap();
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 26.0)).unwrap();
let hyphens = |hide_deleted: bool| {
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { hide_deleted, ..Default::default() });
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
para.lines.iter().filter(|l| l.hyphen.is_some()).count()
};
// With markup the deleted soft hyphen is still there, and the word breaks at it.
assert_eq!(hyphens(false), 1);
// Final text: the soft hyphen is gone, so is the hyphen.
assert_eq!(hyphens(true), 0);
}
#[test]
fn deleted_text_adds_no_line_break_opportunity_without_markup() {
// "xx ab\u{ad}cdef" in a column where "xx ab-" and "abcdef" fit but "xx abcdef" does not; only
// the soft hyphen (5..7) is track-deleted.
let mut d = Document::from_text("xx ab\u{ad}cdef");
d.format_range(&Pos::body(0, 5), &Pos::body(0, 7), &|c| c.del = Some(0)).unwrap();
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 40.0)).unwrap();
let line_starts = |hide_deleted: bool| {
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { hide_deleted, ..Default::default() });
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
para.lines.iter().map(|l| para.clusters.get(l.c0).map_or(usize::MAX, |c| c.start)).collect::<Vec<_>>()
};
// With markup the word breaks at the (deleted) soft hyphen.
assert_eq!(line_starts(false), vec![0, 7]);
// Final text: "abcdef" is one word, so the line breaks at the space before it.
assert_eq!(line_starts(true), vec![0, 3]);
}
#[test]
fn hidden_soft_hyphen_is_no_hyphenation_point_when_hidden_text_is_not_shown() {
// As with deletions: "ab\u{ad}cdef" where only the soft hyphen is hidden text.
let mut d = Document::from_text("ab\u{ad}cdef");
d.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| c.hidden = Some(true)).unwrap();
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 26.0)).unwrap();
let hyphens = |show_hidden: bool| {
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() });
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
para.lines.iter().filter(|l| l.hyphen.is_some()).count()
};
// Hidden text shown: the soft hyphen is there, and the word breaks at it.
assert_eq!(hyphens(true), 1);
// Not shown (as printed): no soft hyphen, so no hyphen. Word breaks "abc" / "def" here.
assert_eq!(hyphens(false), 0);
}
#[test]
fn hidden_text_adds_no_line_break_opportunity_when_not_shown() {
// "xx ab" + hidden soft hyphen or space + "cdef", in a column where "xx ab-" and "abcdef" fit
// but "xx abcdef" does not. Measured in Word (hidden text not printed): "xx" / "abcdef" both times.
for hidden in ["\u{ad}", " "] {
let mut d = Document::from_text(&format!("xx ab{hidden}cdef"));
let end = 5 + hidden.len();
d.format_range(&Pos::body(0, 5), &Pos::body(0, end), &|c| c.hidden = Some(true)).unwrap();
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 40.0)).unwrap();
let line_starts = |show_hidden: bool| {
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() });
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
para.lines.iter().map(|l| para.clusters.get(l.c0).map_or(usize::MAX, |c| c.start)).collect::<Vec<_>>()
};
// Shown, the word breaks at the hidden soft hyphen or space.
assert_eq!(line_starts(true), vec![0, end], "{hidden:?}");
// Not shown, "abcdef" is one word, so the line breaks at the space before it.
assert_eq!(line_starts(false), vec![0, 3], "{hidden:?}");
}
}
/// How much bigger the second document is in the linear-time tests. At 8x a linear layout costs
/// about 8x as much and a quadratic one about 64x, so a limit of 24x stays far from both.
const SCALE: usize = 8;
/// The CPU time `f` takes on this thread, in seconds. On Linux it is the scheduler's count of the
/// time the thread actually ran, so the time it waits while other work on a busy machine has the
/// CPU is left out; elsewhere, wall-clock time.
fn cpu_cost(f: &mut impl FnMut()) -> f64 {
let ran = || {
let s = std::fs::read_to_string("/proc/thread-self/schedstat").ok()?;
s.split_whitespace().next()?.parse::<u64>().ok()
};
let (t, before) = (std::time::Instant::now(), ran());
f();
match (before, ran()) {
(Some(a), Some(b)) => b.saturating_sub(a) as f64 * 1e-9,
_ => t.elapsed().as_secs_f64(),
}
}
/// How much more `big` costs than `small`, for `big` an input `scale` times the size of `small`'s:
/// (small, big, big / small), ~`scale` when linear and ~`scale`² when quadratic. Each is the
/// cheapest of several runs after a warm-up, the runs alternating so a busy spell slows both sizes
/// alike; `small` is timed `scale` times over per run, so both timings are long against the clock's
/// resolution.
fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) {
small();
big();
let mut small_scaled = || (0..scale).for_each(|_| small());
let (mut t1, mut tk) = (f64::INFINITY, f64::INFINITY);
for _ in 0..5 {
t1 = t1.min(cpu_cost(&mut small_scaled) / scale as f64);
tk = tk.min(cpu_cost(&mut big));
}
(t1, tk, tk / t1.max(1e-9))
}
/// [`cost_ratio`] of laying out `doc(SCALE * n)` against `doc(n)`.
fn layout_time_ratio(doc: impl Fn(usize) -> Document, n: usize, opts: &LayoutOptions) -> (f64, f64, f64) {
let (small, big) = (doc(n), doc(SCALE * n));
cost_ratio(
SCALE,
|| {
layout(&small, &mut LayoutCache::new(), opts);
},
|| {
layout(&big, &mut LayoutCache::new(), opts);
},
)
}
#[test]
fn hidden_separators_with_automatic_hyphenation_lay_out_in_linear_time() {
// "hyphenation " n times with every space hidden: one joined word of 11n letters reaches the
// hyphenator, whose limit checks once rescanned the word for every candidate point.
let doc = |n: usize| {
let mut d = Document::from_text(&"hyphenation ".repeat(n));
d.settings.auto_hyphenation = true;
for k in 0..n {
let at = 12 * k + 11;
d.format_range(&Pos::body(0, at), &Pos::body(0, at + 1), &|c| c.hidden = Some(true)).unwrap();
}
d
};
let (t1, tk, ratio) = layout_time_ratio(doc, 800, &LayoutOptions::default());
eprintln!("t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}");
assert!(ratio < 24.0, "8x the text took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
}
#[test]
fn many_hidden_float_anchors_lay_out_in_linear_time() {
// n square-wrapped shapes, each anchored in its own hidden run (bold alternating, so runs do not
// merge), markup on: finding each anchor's formatting once rescanned the runs from the start.
let doc = |n: usize| {
let float = wordcraft_doc::para::Float {
wrap: wordcraft_doc::para::Wrap::Square,
h_rel: wordcraft_doc::para::Anchor::Column,
v_rel: wordcraft_doc::para::Anchor::Paragraph,
x: 0.0,
y: 0.0,
dist: 9.0,
dist_top: 9.0,
dist_bottom: 9.0,
..Default::default()
};
let shape = InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w: 20.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
let obj = wordcraft_doc::para::OBJ.to_string();
let mut p = wordcraft_doc::Paragraph::with_text("Text ", Default::default());
p.text.push_str(&obj.repeat(n));
p.objects = vec![shape; n];
p.runs = std::iter::once(wordcraft_doc::Run { len: 5, props: Default::default() })
.chain((0..n).map(|i| wordcraft_doc::Run {
len: obj.len(),
props: wordcraft_doc::CharProps { hidden: Some(true), bold: Some(i % 2 == 0), ..Default::default() },
}))
.collect();
let mut d = Document::from_text("");
d.body = vec![wordcraft_doc::para_block(p)];
d
};
// Correct first: hidden anchors place no shape, shown ones do.
let shapes = |show_hidden: bool| {
let l = layout(&doc(50), &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() });
l.pages.iter().flat_map(|p| p.items.iter()).filter(|i| matches!(i, Placed::Shape { .. })).count()
};
assert_eq!((shapes(false), shapes(true)), (0, 50));
let (t1, tk, ratio) = layout_time_ratio(doc, 10_000, &LayoutOptions::default());
eprintln!("t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}");
assert!(ratio < 24.0, "8x the anchors took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
}
#[test]
fn fragmented_hidden_text_lays_out_like_the_visible_text() {
// Visible words with a hidden multi-byte char after every visible one, formatted in alternating
// runs (bold on and off) so no two left-out runs are adjacent or merge. The plain paragraph has
// the same visible runs without the hidden chars, so both lay out the same widths whatever font
// the default font resolves to.
let words = "Hyphenation wraps international words across narrow columns of text ".repeat(6);
let mut d = Document::from_text("");
d.settings.auto_hyphenation = true;
let mut plain = Document::from_text("");
plain.settings.auto_hyphenation = true;
let mut text = String::new();
let mut kept = Vec::new();
for (i, c) in words.chars().enumerate() {
kept.push(text.len());
text.push(c);
let at = text.len();
text.push('é');
let n = d.para(StoryRef::Body, &Path::top(0)).unwrap().len();
let bold = wordcraft_doc::CharProps { bold: Some(i % 2 == 0), ..Default::default() };
d.insert_text(&Pos::body(0, n), &c.to_string(), &bold).unwrap();
let hidden = wordcraft_doc::CharProps { hidden: Some(true), bold: Some(i % 2 == 1), ..Default::default() };
d.insert_text(&Pos::body(0, at), "é", &hidden).unwrap();
let n = plain.para(StoryRef::Body, &Path::top(0)).unwrap().len();
plain.insert_text(&Pos::body(0, n), &c.to_string(), &bold).unwrap();
}
assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, text);
assert_eq!(plain.para(StoryRef::Body, &Path::top(0)).unwrap().text, words);
for doc in [&mut d, &mut plain] {
doc.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 90.0)).unwrap();
}
// Line starts and hyphenation points, as paragraph offsets.
let shape = |d: &Document| {
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
let paras: Vec<_> = l
.pages
.iter()
.flat_map(|p| p.items.iter())
.filter_map(|i| if let Placed::Lines { para, .. } = i { Some(para.clone()) } else { None })
.collect();
let para = &paras[0];
let starts: Vec<usize> = para.lines.iter().map(|l| para.clusters.get(l.c0).map_or(usize::MAX, |c| c.start)).collect();
let hyph: Vec<usize> = para.hyph_after.iter().map(|k| para.clusters[*k as usize].end).collect();
(starts, hyph)
};
let (starts, hyph) = shape(&plain);
assert!(starts.len() > 5 && !hyph.is_empty(), "{starts:?} {hyph:?}");
// Mapped to the fragmented paragraph: a line starts after the previous visible char (with the
// hidden one that follows it, which takes no room), a hyphen point is after its char.
let want_starts: Vec<usize> = starts.iter().map(|s| if *s == 0 { 0 } else { kept[s - 1] + 1 }).collect();
let want_hyph: Vec<usize> = hyph.iter().map(|e| kept[e - 1] + 1).collect();
assert_eq!(shape(&d), (want_starts, want_hyph));
}
#[test]
fn automatic_hyphenation_sees_only_the_text_laid_out() {
// Paragraph-text ends of the clusters after which a line may end with a hyphen.
let hyph_ends = |d: &Document, opts: &LayoutOptions| {
let l = layout(d, &mut LayoutCache::new(), opts);
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
para.hyph_after.iter().map(|k| para.clusters[*k as usize].end).collect::<Vec<_>>()
};
let doc = |text: &str| {
let mut d = Document::from_text(text);
d.settings.auto_hyphenation = true;
d
};
// (text, hidden bytes, deleted bytes, the text as laid out without them).
let cases = [
// A hidden prefix: "table" must not break after its first letter as "acceptable" may.
("acceptable", 0..5, 0..0, "table"),
// A hidden separator joins two words into one.
("foot ball", 4..5, 0..0, "football"),
// Hidden text and a tracked deletion together.
("accXeptYable", 7..8, 3..4, "acceptable"),
];
let hidden_opts = LayoutOptions { hide_deleted: true, ..Default::default() };
for (text, hidden, deleted, visible) in cases {
let mut d = doc(text);
d.format_range(&Pos::body(0, hidden.start), &Pos::body(0, hidden.end), &|c| c.hidden = Some(true)).unwrap();
if !deleted.is_empty() {
d.format_range(&Pos::body(0, deleted.start), &Pos::body(0, deleted.end), &|c| c.del = Some(0)).unwrap();
}
// The same points as the visible text alone, at the matching paragraph offsets.
let kept: Vec<usize> = (0..text.len()).filter(|i| !hidden.contains(i) && !deleted.contains(i)).collect();
let want: Vec<usize> = hyph_ends(&doc(visible), &hidden_opts).iter().map(|e| kept[e - 1] + 1).collect();
assert_eq!(hyph_ends(&d, &hidden_opts), want, "{text:?} as {visible:?}");
// Hidden text shown and markup shown: the whole text is hyphenated as before.
let all = LayoutOptions { show_hidden: true, ..Default::default() };
assert_eq!(hyph_ends(&d, &all), hyph_ends(&doc(text), &all), "{text:?} shown");
}
}
#[test]
fn page_break_char_starts_new_page() {
let mut d = Document::from_text("one");
d.insert_text(&Pos::body(0, 3), "\u{000C}", &Default::default()).unwrap();
let l = lay(&d);
assert_eq!(l.pages.len(), 2);
}
#[test]
fn center_and_right_alignment() {
let mut d = Document::from_text("abc\nabc\nabc");
d.format_paragraphs(&Pos::body(1, 0), &Pos::body(1, 0), &|p| p.align = Some(Align::Center)).unwrap();
d.format_paragraphs(&Pos::body(2, 0), &Pos::body(2, 0), &|p| p.align = Some(Align::Right)).unwrap();
let l = lay(&d);
let x0 = l.caret(&Pos::body(0, 0)).unwrap().x;
let x1 = l.caret(&Pos::body(1, 0)).unwrap().x;
let x2 = l.caret(&Pos::body(2, 3)).unwrap().x;
assert!((x0 - 72.0).abs() < 0.5);
assert!(x1 > 250.0 && x1 < 320.0, "{x1}");
assert!((x2 - 540.0).abs() < 1.0, "{x2}");
}
#[test]
fn tabs_land_on_default_stops() {
let d = Document::from_text("a\tb");
let l = lay(&d);
let xb = l.caret(&Pos::body(0, 2)).unwrap().x;
assert!((xb - (72.0 + 36.0)).abs() < 0.5, "{xb}");
}
#[test]
fn right_tab_aligns_text_end() {
let mut d = Document::from_text("left\tright");
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| {
p.tabs = Some(vec![wordcraft_doc::props::TabStop { pos: 400.0, align: wordcraft_doc::props::TabAlign::Right, leader: Default::default() }])
})
.unwrap();
let l = lay(&d);
let end = l.caret(&Pos::body(0, 10)).unwrap().x;
assert!((end - 472.0).abs() < 1.0, "{end}");
}
#[test]
fn lists_get_labels_and_indent() {
let mut d = Document::from_text("one\ntwo\nthree");
let id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(2, 5), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
let l = lay(&d);
let labels: Vec<String> = l.pages[0]
.items
.iter()
.filter_map(|it| if let Placed::Lines { para, .. } = it { para.label.as_ref().map(|x| x.text.clone()) } else { None })
.collect();
assert_eq!(labels, vec!["1.", "2.", "3."]);
let x = l.caret(&Pos::body(0, 0)).unwrap().x;
assert!((x - 108.0).abs() < 0.5, "{x}");
}
#[test]
fn chinese_counting_list_labels_and_their_font_runs() {
// Issue #534: a "一、" list showed digits, drawn with glyph ids of another font.
use wordcraft_doc::section::NumFormat;
let mut d = Document::from_text("甲\n乙\n丙");
let id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
for a in &mut d.numbering.abstracts {
if let Some(l) = a.levels.first_mut() {
l.format = NumFormat::ChineseCounting;
l.text = "%1、".into();
}
}
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(2, 3), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
let labels = |d: &Document| -> Vec<(String, Vec<(String, bool)>)> {
let l = lay(d);
l.pages[0]
.items
.iter()
.filter_map(|it| if let Placed::Lines { para, .. } = it { Some(para.clone()) } else { None })
.filter_map(|pl| {
let lab = pl.label.clone()?;
// Every run draws glyphs of its own face.
let runs = lab
.runs
.iter()
.map(|r| {
let face = &pl.styles[r.style as usize].face;
let gids: Vec<u32> = lab.glyphs[r.glyphs.clone()].iter().map(|g| g.gid).collect();
(r.text.clone(), r.text.chars().all(|c| gids.contains(&face.glyph_for(c))))
})
.collect();
Some((lab.text, runs))
})
.collect()
};
let got = labels(&d);
assert_eq!(got.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), ["一、", "二、", "三、"]);
// A decimal label next to a CJK comma mixes faces when the Latin face lacks the comma.
for a in &mut d.numbering.abstracts {
if let Some(l) = a.levels.first_mut() {
l.format = NumFormat::Decimal;
}
}
for (text, runs) in labels(&d).into_iter().chain(got) {
assert_eq!(runs.iter().map(|(t, _)| t.as_str()).collect::<String>(), text);
assert!(runs.iter().all(|(_, own)| *own), "{text}: {runs:?}");
}
}
#[test]
fn symbol_font_bullet_without_the_font_draws_a_bullet() {
// Word's default bullet: U+F0B7 in Symbol.
let mut d = Document::from_text("one");
let id = d.numbering.add_list(wordcraft_doc::ListKind::BulletChar('\u{F0B7}'));
for a in &mut d.numbering.abstracts {
if let Some(l) = a.levels.first_mut() {
l.chr.font = Some("Symbol".into());
}
}
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
let l = lay(&d);
let label = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.label.clone() } else { None }).unwrap();
// Where a real Symbol font with the private-use code is installed (Windows), the code stays.
let symbol = wordcraft_fonts::word::resolve("Symbol", false, false);
let expected = if symbol.substituted || !symbol.face.covers('\u{F0B7}') { "\u{2022}" } else { "\u{F0B7}" };
assert_eq!(label.text, expected);
assert_eq!(label.glyphs.len(), 1);
}
#[test]
fn tables_lay_out_cells() {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(2, 3, 468.0);
t.rows[0].cells[1].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("cell text", Default::default()))];
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let pos = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off: 0 };
let c = l.caret(&pos).unwrap();
assert!(c.x > 72.0 + 150.0 && c.x < 72.0 + 170.0, "{c:?}");
let after = l.caret(&Pos::body(2, 0)).unwrap();
assert!(after.top > c.top + 20.0);
let rules = l.pages[0].items.iter().filter(|i| matches!(i, Placed::Rule { .. })).count();
assert!(rules >= 12, "rules {rules}");
assert!(l.cell_at(0, c.x, c.top + 2.0).is_some());
}
/// A vertical merge is one cell: its box spans the rows, a click anywhere in it finds it, and the
/// continuation row's box is `covered` (no gridline, not a hit).
#[test]
fn a_vertical_merge_lays_out_as_one_cell() {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(3, 2, 468.0);
t.rows[0].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Restart;
t.rows[1].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Continue;
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let cells: Vec<_> = l.pages[0]
.items
.iter()
.filter_map(|i| if let Placed::Cell { rect, row, cell: 0, covered, .. } = i { Some((*row, *rect, *covered)) } else { None })
.collect();
let (r0, r1) = (cells[0].1, cells[1].1);
assert!(!cells[0].2 && cells[1].2 && !cells[2].2, "{cells:?}");
assert!((r0.bottom() - r1.bottom()).abs() < 0.01 && r0.h > r1.h * 1.9, "{r0:?} {r1:?}");
// The lower half of the merge hits the restart cell (row 0), not the continuation row.
let hit = l.cell_at(0, r1.x + 5.0, r1.y + r1.h / 2.0).unwrap();
assert_eq!((hit.2, hit.3), (0, 0));
}
/// A 1×3 table after "before" whose middle cell holds `text` running `dir`.
fn turned_table(dir: TextDirection, text: &str, row: Option<f32>) -> Document {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(1, 3, 468.0);
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("plain", Default::default()))];
t.rows[0].cells[1].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
t.rows[0].cells[1].props.text_direction = dir;
if let Some(h) = row {
t.rows[0].props.height = Some(h);
t.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Exact;
}
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
d
}
fn turned_lines(l: &DocLayout) -> &Placed {
l.pages[0].items.iter().find(|it| matches!(it, Placed::Lines { path, .. } if path.0 == [1, 0, 1, 0])).unwrap()
}
/// Table Layout › Text Direction (#226): turned cell text runs along the cell's height, the row
/// grows to fit it, and caret, clicks and drawing follow the turn.
#[test]
fn turned_cell_text_runs_down_the_cell() {
let text = "Turned cell text";
let d = turned_table(TextDirection::Down, text, None);
let l = lay(&d);
let it = turned_lines(&l);
assert!(matches!(it, Placed::Lines { turn: TextDirection::Down, l0: 0, l1: 1, .. }), "one unwrapped line: {it:?}");
let b = it.turned_bounds().unwrap();
// Tall and narrow, against the cell's right edge (cell 2 spans x 228..384, 5.4pt margins).
assert!(b.h > 60.0 && b.w < 20.0, "{b:?}");
assert!((b.right() - (72.0 + 312.0 - 5.4)).abs() < 1.0 && b.x > 72.0 + 156.0, "{b:?}");
// The row grew to hold the text: the next paragraph starts below it.
let after = l.caret(&Pos::body(2, 0)).unwrap();
assert!(after.top > b.bottom(), "{after:?} vs {b:?}");
let cell = l.pages[0].items.iter().find_map(|i| if let Placed::Cell { rect, cell: 1, .. } = i { Some(*rect) } else { None }).unwrap();
assert!(cell.h >= b.h, "{cell:?}");
// The caret lies across the page and moves down as the text goes on.
let pos = |off| Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off };
let c0 = l.caret(&pos(0)).unwrap();
let c1 = l.caret(&pos(text.len())).unwrap();
assert!(c0.width > 5.0 && c0.height == 0.0, "{c0:?}");
assert!((c0.top - b.y).abs() < 1.0 && c1.top > c0.top + 50.0, "{c0:?} {c1:?}");
assert!(c0.x >= b.x - 0.5 && c0.x + c0.width <= b.right() + 0.5, "{c0:?} {b:?}");
// A click on the turned text lands in it, by how far down the click is.
let hit = l.hit(0, b.x + b.w / 2.0, b.y + b.h * 0.6, StoryRef::Body).unwrap();
assert_eq!(hit.path, Path(vec![1, 0, 1, 0]));
assert!(hit.off > 3 && hit.off < text.len(), "{hit:?}");
// Selection highlights are turned too: tall, inside the text's area.
let sel = l.selection_rects(&d, &pos(0), &pos(text.len()), 0);
assert!(sel.iter().all(|(_, r)| r.h > r.w && r.x >= b.x - 1.0 && r.right() <= b.right() + 1.0), "{sel:?}");
// Drawn in a turned frame.
let draws = display::page_display(&d, &l.pages[0], &Default::default());
let turned = draws.iter().find_map(|x| if let display::Draw::Turned { turn, items, .. } = x { Some((*turn, items)) } else { None });
let (turn, items) = turned.expect("turned draw");
assert_eq!(turn, TextDirection::Down);
assert!(items.iter().any(|x| matches!(x, display::Draw::Glyphs { text, .. } if text.contains("Turned"))), "{items:?}");
}
#[test]
fn turned_up_cell_text_reads_bottom_to_top() {
let text = "Bottom to top";
let d = turned_table(TextDirection::Up, text, None);
let l = lay(&d);
let b = turned_lines(&l).turned_bounds().unwrap();
// Against the cell's left edge; the text starts at the bottom and climbs.
assert!((b.x - (72.0 + 156.0 + 5.4)).abs() < 1.0, "{b:?}");
let pos = |off| Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off };
let (c0, c1) = (l.caret(&pos(0)).unwrap(), l.caret(&pos(text.len())).unwrap());
assert!((c0.top - b.bottom()).abs() < 1.0 && c1.top < c0.top - 50.0, "{c0:?} {c1:?} {b:?}");
let hit = l.hit(0, b.x + b.w / 2.0, b.bottom() - 2.0, StoryRef::Body).unwrap();
assert_eq!((hit.path, hit.off), (Path(vec![1, 0, 1, 0]), 0));
}
#[test]
fn turned_text_wraps_in_an_exact_row() {
let d = turned_table(TextDirection::Down, &"word ".repeat(30), Some(72.0));
let l = lay(&d);
let it = turned_lines(&l);
let lines = if let Placed::Lines { l0, l1, .. } = it { l1 - l0 } else { 0 };
assert!(lines > 2, "the text wraps at the row height");
let b = it.turned_bounds().unwrap();
assert!(b.h <= 72.0 && b.w > 30.0, "{b:?}");
let after = l.caret(&Pos::body(2, 0)).unwrap();
assert!(after.top < b.y + 72.0 + 30.0, "the row keeps its exact height: {after:?}");
}
#[test]
fn hostile_turned_cells_never_panic() {
// Empty, huge and nested turned cells, in exact rows too small for anything.
for dir in [TextDirection::Down, TextDirection::Up] {
for row in [None, Some(0.5), Some(1e9)] {
let mut d = turned_table(dir, "", row);
let l = lay(&d);
let _ = l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off: 0 });
let mut inner = Table::new(1, 1, 20.0);
inner.rows[0].cells[0].props.text_direction = dir;
if let Some(Block::Table(t)) = d.body.get_mut(1).map(Arc::make_mut) {
t.rows[0].cells[1].blocks.insert(0, Arc::new(Block::Table(inner)));
}
let l = lay(&d);
for p in &l.pages {
let _ = display::page_display(&d, p, &Default::default());
}
}
}
let l = lay(&turned_table(TextDirection::Down, &"long ".repeat(5000), None));
assert!(!l.pages.is_empty());
}
fn rules_on_page0(l: &DocLayout) -> Vec<f32> {
l.pages[0].items.iter().filter_map(|i| if let Placed::Rule { border, .. } = i { Some(border.width) } else { None }).collect()
}
#[test]
fn table_style_borders_survive_empty_tbl_borders() {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(2, 2, 468.0); // defaults to the TableGrid style
t.props.borders = Some(Borders::default()); // what an empty <w:tblBorders/> parses to
for r in &mut t.rows {
for c in &mut r.cells {
c.props.borders = Some(Borders::default());
}
}
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
assert!(rules_on_page0(&l).len() >= 8, "rules {:?}", rules_on_page0(&l));
}
#[test]
fn table_own_border_overrides_only_that_side() {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(2, 2, 468.0);
t.props.borders = Some(Borders { top: Some(Border::single(2.0)), ..Default::default() });
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let w = rules_on_page0(&l);
assert!(w.contains(&2.0), "own top missing: {w:?}");
assert!(w.iter().filter(|x| (**x - 0.5).abs() < 1e-6).count() >= 6, "style sides missing: {w:?}");
}
#[test]
fn table_nil_border_hides_style_side() {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(1, 1, 468.0); // 1×1: no inside edges
t.props.borders = Some(Borders::all(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 }));
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
assert!(rules_on_page0(&lay(&d)).is_empty());
}
#[test]
fn table_without_style_stays_borderless() {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(2, 2, 468.0);
t.props.style = None;
t.props.borders = Some(Borders::default());
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
assert!(rules_on_page0(&lay(&d)).is_empty());
}
#[test]
fn headers_and_page_fields() {
let mut d = Document::from_text(&"para\n".repeat(120));
let mut hp = wordcraft_doc::Paragraph::new();
hp.insert_object(0, InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false }, &Default::default())
.unwrap();
let id = d.add_part(wordcraft_doc::PartKind::Footer, vec![wordcraft_doc::para_block(hp)]);
d.last_section.footers.default = Some(id);
let l = lay(&d);
assert!(l.pages.len() >= 2);
for (i, p) in l.pages.iter().enumerate() {
assert_eq!(p.footer_story, Some(id));
let label = p.footer.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some(para.clusters.len()) } else { None });
assert!(label.is_some(), "page {i}");
}
let c = l.caret_on(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, 1).unwrap();
assert_eq!(c.page, 1);
assert!(c.top > 700.0);
}
#[test]
fn tall_first_page_header_pushes_body_down() {
let mut d = Document::from_text(&"para\n".repeat(120));
let tall: Vec<_> = (0..12).map(|_| wordcraft_doc::para_block(wordcraft_doc::Paragraph::new())).collect();
let id = d.add_part(wordcraft_doc::PartKind::Header, tall);
d.last_section.headers.first = Some(id);
d.last_section.title_page = true;
let l = lay(&d);
let first = l.caret(&Pos::body(0, 0)).unwrap();
assert!(first.top > d.last_section.margin_top + 50.0, "{first:?}");
// The first paragraph on page 2 (which has no header) sits higher than page 1's first line,
// whatever the installed fonts do to pagination.
let later = (0..120).filter_map(|i| l.caret(&Pos::body(i, 0))).find(|c| c.page == 1).unwrap();
assert!(later.top < first.top, "{later:?}");
}
#[test]
fn cache_reuses_unchanged_paragraphs() {
let d = Document::from_text(&"some text here\n".repeat(50));
let mut c = LayoutCache::new();
layout(&d, &mut c, &LayoutOptions::default());
let misses = c.misses;
let mut d2 = d.clone();
d2.insert_text(&Pos::body(10, 0), "x", &Default::default()).unwrap();
layout(&d2, &mut c, &LayoutOptions::default());
assert_eq!(c.misses, misses + 1);
}
#[test]
fn selection_rects_cover_range() {
let d = Document::from_text("Hello world\nSecond line");
let l = lay(&d);
let r = l.selection_rects(&d, &Pos::body(0, 6), &Pos::body(1, 6), 0);
assert_eq!(r.len(), 2);
assert!(r[0].1.w > 20.0);
}
#[test]
fn column_segments_take_the_same_x_range_on_every_line() {
let d = Document::from_text("abcdef\nabcdef\nab");
let l = lay(&d);
let left = l.caret(&Pos::body(0, 2)).unwrap().x;
let right = l.caret(&Pos::body(0, 4)).unwrap().x;
let segs = l.column_segments(&d, &Pos::body(2, 2), &Pos::body(0, 2), right, left, 0, 100);
let offs: Vec<(u32, usize, usize)> = segs.iter().map(|(a, b)| (a.path.0[0], a.off, b.off)).collect();
assert_eq!(offs, vec![(0, 2, 4), (1, 2, 4), (2, 2, 2)]);
// Capped, and junk x gives nothing.
assert_eq!(l.column_segments(&d, &Pos::body(0, 2), &Pos::body(2, 2), left, right, 0, 2).len(), 2);
assert!(l.column_segments(&d, &Pos::body(0, 2), &Pos::body(2, 2), f32::NAN, right, 0, 100).is_empty());
}
#[test]
fn display_has_glyphs_and_marks() {
let mut d = Document::from_text("Hello\tworld");
d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.underline = Some(wordcraft_doc::props::Underline::Single)).unwrap();
let l = lay(&d);
let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions { marks: true, ..Default::default() });
assert!(items.iter().any(|i| matches!(i, display::Draw::Glyphs { .. })));
assert!(items.iter().any(|i| matches!(i, display::Draw::Line { .. })));
assert!(items.iter().any(|i| matches!(i, display::Draw::Mark { ch: '', .. })));
assert!(items.iter().any(|i| matches!(i, display::Draw::Mark { ch: '→', .. })));
}
#[test]
fn a_tracked_paragraph_mark_shows_in_its_authors_colour() {
let mut d = Document::from_text("Split\nhere");
d.revisions.push(wordcraft_doc::Revision {
kind: wordcraft_doc::RevisionKind::Insert,
author: "Ana".into(),
date: String::new(),
move_name: None,
});
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().mark.ins = Some(0);
let l = lay(&d);
let marks = |markup: bool| -> Vec<Option<wordcraft_doc::props::Rgb>> {
display::page_display(&d, &l.pages[0], &display::DisplayOptions { marks: true, markup, ..Default::default() })
.into_iter()
.filter_map(|i| if let display::Draw::Mark { ch: '', color, .. } = i { Some(color) } else { None })
.collect()
};
assert_eq!(marks(true), [Some(display::revision_color(0)), None]);
assert_eq!(marks(false), [None, None]);
}
#[test]
fn section_breaks_show_with_formatting_marks() {
use wordcraft_doc::section::{SectionProps, SectionStart};
// Two sections: "One" ends the first; the second starts as `start`.
let doc = |start: SectionStart| {
let mut d = Document::from_text("One\nTwo");
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
d.last_section.start = start;
d
};
let labels = |d: &Document, marks: bool| -> (Vec<String>, usize) {
let l = lay(d);
let items = display::page_display(d, &l.pages[0], &display::DisplayOptions { marks, ..Default::default() });
let texts = items.iter().filter_map(|i| if let display::Draw::MarkText { text, .. } = i { Some(text.clone()) } else { None }).collect();
let pilcrows = items.iter().filter(|i| matches!(i, display::Draw::Mark { ch: '', .. })).count();
(texts, pilcrows)
};
let d = doc(SectionStart::Continuous);
// The break replaces the first paragraph's ¶; the last paragraph keeps its own.
assert_eq!(labels(&d, true), (vec!["Section Break (Continuous)".to_string()], 1));
assert_eq!(labels(&d, false), (vec![], 0));
assert_eq!(labels(&doc(SectionStart::NextPage), true).0, ["Section Break (Next Page)"]);
assert_eq!(labels(&doc(SectionStart::EvenPage), true).0, ["Section Break (Even Page)"]);
assert_eq!(labels(&doc(SectionStart::OddPage), true).0, ["Section Break (Odd Page)"]);
// A document with one section has no break.
assert_eq!(labels(&Document::from_text("One\nTwo"), true), (vec![], 2));
}
#[test]
fn space_between_paragraphs_is_the_larger_of_after_and_before() {
// (space after the first, space before the second, contextual spacing on both) -> gap
let gap = |after: f32, before: f32, contextual: bool| -> f32 {
let mut d = Document::from_text("One\nTwo");
for (i, (a, b)) in [(after, 0.0), (0.0, before)].into_iter().enumerate() {
let p = d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap();
(p.props.space_after, p.props.space_before, p.props.contextual_spacing) = (Some(a), Some(b), Some(contextual));
}
let l = lay(&d);
let ys: Vec<(f32, f32)> =
l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, para, .. } = i { Some((*y, para.height)) } else { None }).collect();
ys[1].0 - (ys[0].0 + ys[0].1)
};
// Word's output for docxide-pdf's clean_energy_regulator_guidelines: 6pt after and 6pt before
// leave 6pt, not 12pt.
assert!((gap(6.0, 6.0, false) - 6.0).abs() < 0.01);
assert!((gap(6.0, 12.0, false) - 12.0).abs() < 0.01);
assert!((gap(12.0, 6.0, false) - 12.0).abs() < 0.01);
assert!((gap(0.0, 6.0, false) - 6.0).abs() < 0.01);
// Contextual spacing drops both between paragraphs of one style.
assert!(gap(6.0, 12.0, true).abs() < 0.01);
}
#[test]
fn space_between_paragraphs_in_a_cell_follows_the_body_rule() {
// Three paragraphs in a cell, each 6pt before and 6pt after -> the gaps between them.
let gaps = |contextual: bool| -> Vec<f32> {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(1, 1, 468.0);
t.rows[0].cells[0].blocks = (0..3)
.map(|k| {
let mut p = wordcraft_doc::Paragraph::with_text(&format!("p{k}"), Default::default());
(p.props.space_after, p.props.space_before, p.props.contextual_spacing) = (Some(6.0), Some(6.0), Some(contextual));
wordcraft_doc::para_block(p)
})
.collect();
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let mut ys: Vec<(u32, f32, f32)> = l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { path, y, para, .. } if path.0.len() == 4 => Some((path.0[3], *y, para.height)),
_ => None,
})
.collect();
ys.sort_by_key(|v| v.0);
ys.windows(2).map(|w| w[1].1 - (w[0].1 + w[0].2)).collect()
};
let near = |got: Vec<f32>, want: [f32; 2]| got.len() == 2 && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.01);
// The larger of after and before, not their sum.
assert!(near(gaps(false), [6.0, 6.0]), "{:?}", gaps(false));
// Contextual spacing drops the space between every pair, not just the first.
assert!(near(gaps(true), [0.0, 0.0]), "{:?}", gaps(true));
}
#[test]
fn sections_land_on_the_side_their_break_and_numbering_ask_for() {
use wordcraft_doc::section::{SectionProps, SectionStart};
// "One" ends the first section on page 1; the second section starts as `start`.
let pages = |start: SectionStart, restart: Option<u32>, even_odd: bool| -> Vec<(usize, u32)> {
let mut d = Document::from_text("One\nTwo");
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
d.last_section.start = start;
d.last_section.page_num_start = restart;
d.settings.even_odd_headers = even_odd;
lay(&d).pages.iter().map(|p| (p.section, p.number)).collect()
};
// An odd-page break after page 1 leaves page 2 blank; the blank page ends the first section.
assert_eq!(pages(SectionStart::OddPage, None, false), [(0, 1), (0, 2), (1, 3)]);
assert_eq!(pages(SectionStart::EvenPage, None, false), [(0, 1), (1, 2)]);
// Restarting at 1 needs no blank page unless both sides print; at 2 it takes the next number.
assert_eq!(pages(SectionStart::OddPage, Some(1), false), [(0, 1), (1, 1)]);
assert_eq!(pages(SectionStart::OddPage, Some(2), false), [(0, 1), (1, 3)]);
assert_eq!(pages(SectionStart::OddPage, Some(1), true), [(0, 1), (0, 2), (1, 1)]);
// With different odd and even headers, a next-page break restarting at 1 starts on an odd
// sheet (Word's output for docxide-pdf's bush_fires_act_comparison).
assert_eq!(pages(SectionStart::NextPage, Some(1), true), [(0, 1), (0, 2), (1, 1)]);
assert_eq!(pages(SectionStart::NextPage, Some(2), true), [(0, 1), (1, 2)]);
assert_eq!(pages(SectionStart::NextPage, Some(1), false), [(0, 1), (1, 1)]);
assert_eq!(pages(SectionStart::NextPage, None, true), [(0, 1), (1, 2)]);
}
#[test]
fn paragraph_marks_sit_past_text_running_the_other_way() {
// A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the
// logically last letters are at the wrong edge, but the ¶ still goes past every glyph.
let mut d = Document::from_text("Hello \u{5e9}\u{5dc}\u{5d5}\u{5dd}\n\u{5e9}\u{5dc}\u{5d5}\u{5dd} abc");
let at = Pos::body(1, 0);
d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.bidi = Some(true)).unwrap();
let l = lay(&d);
// Leftmost and rightmost glyph edges (page x) of each paragraph's single line.
let extents: Vec<(f32, f32)> = l.pages[0]
.items
.iter()
.filter_map(|it| if let Placed::Lines { para, x, .. } = it { Some((para, *x)) } else { None })
.map(|(pl, x)| {
let ln = &pl.lines[0];
let edges = (ln.c0..ln.c1).filter_map(|k| Some((ln.cl_left(k)?, ln.cl_right(k)?)));
edges.fold((f32::MAX, f32::MIN), |(a, b), (l, r)| (a.min(x + l), b.max(x + r)))
})
.collect();
let marks: Vec<(f32, f32)> = display::page_display(&d, &l.pages[0], &display::DisplayOptions { marks: true, ..Default::default() })
.into_iter()
.filter_map(|i| if let display::Draw::Mark { ch: '', x, size, .. } = i { Some((x, size)) } else { None })
.collect();
assert_eq!((extents.len(), marks.len()), (2, 2));
let ((ltr_x, _), (rtl_x, size)) = (marks[0], marks[1]);
assert!(ltr_x >= extents[0].1, "LTR ¶ at {ltr_x} should be right of all glyphs {:?}", extents[0]);
assert!(rtl_x + size * 0.5 <= extents[1].0, "RTL ¶ at {rtl_x} should be left of all glyphs {:?}", extents[1]);
}
fn border_lines(d: &Document) -> Vec<(f32, f32, f32, f32)> {
border_lines_in(d, &lay(d))
}
fn border_lines_in(d: &Document, l: &DocLayout) -> Vec<(f32, f32, f32, f32)> {
display::page_display(d, &l.pages[0], &display::DisplayOptions::default())
.into_iter()
.filter_map(|i| if let display::Draw::Line { x0, y0, x1, y1, .. } = i { Some((x0, y0, x1, y1)) } else { None })
.collect()
}
#[test]
fn character_border_draws_one_box_around_run() {
use wordcraft_doc::props::Border;
let mut d = Document::from_text("Hello world");
assert!(border_lines(&d).is_empty());
d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.border = Some(Border::single(0.5))).unwrap();
// One layout for both the draw list and the expected geometry: the background font scan can change metrics between layouts.
let l = lay(&d);
let lines = border_lines_in(&d, &l);
assert_eq!(lines.len(), 4, "{lines:?}");
let Some(Placed::Lines { para, x, y, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
let (gx0, gx1) = (x + para.x_of(0, 0).unwrap(), x + para.x_of(0, 5).unwrap());
let (top, bottom) = (*y, y + para.lines[0].height);
let horiz: Vec<_> = lines.iter().filter(|l| l.1 == l.3).collect();
let vert: Vec<_> = lines.iter().filter(|l| l.0 == l.2).collect();
assert_eq!(horiz.len(), 2);
assert_eq!(vert.len(), 2);
assert!(horiz.iter().any(|h| (h.1 - top).abs() < 0.01) && horiz.iter().any(|h| (h.1 - bottom).abs() < 0.01), "{lines:?}");
assert!(horiz.iter().all(|h| (h.0 - gx0).abs() < 0.01 && (h.2 - gx1).abs() < 0.01), "{lines:?} {gx0} {gx1}");
}
#[test]
fn equal_adjacent_borders_share_a_box() {
use wordcraft_doc::props::Border;
let mut d = Document::from_text("ab");
d.format_range(&Pos::body(0, 0), &Pos::body(0, 2), &|c| c.border = Some(Border::single(0.5))).unwrap();
d.format_range(&Pos::body(0, 1), &Pos::body(0, 2), &|c| c.bold = Some(true)).unwrap();
assert_eq!(border_lines(&d).len(), 4);
d.format_range(&Pos::body(0, 1), &Pos::body(0, 2), &|c| c.border = Some(Border::single(1.0))).unwrap();
assert_eq!(border_lines(&d).len(), 8);
}
#[test]
fn wrapped_border_boxes_each_line() {
use wordcraft_doc::props::Border;
let text = "boxed ".repeat(20);
let mut d = Document::from_text(text.trim_end());
let n = d.para_at(&Pos::body(0, 0)).unwrap().len();
d.format_range(&Pos::body(0, 0), &Pos::body(0, n), &|c| c.border = Some(Border::single(0.5))).unwrap();
assert_eq!(lines_of(&lay(&d)), 2);
assert_eq!(border_lines(&d).len(), 8);
}
#[test]
fn runs_differing_only_in_link_or_decoration_keep_their_own_style() {
let mut d = Document::from_text("Alpha Beta Gamma Delta");
let set = |d: &mut Document, a: usize, b: usize, f: &dyn Fn(&mut wordcraft_doc::props::CharProps)| {
d.format_range(&Pos::body(0, a), &Pos::body(0, b), f).unwrap();
};
set(&mut d, 0, 5, &|c| c.link = Some("https://a.example/".into()));
set(&mut d, 6, 10, &|c| c.link = Some("https://b.example/".into()));
set(&mut d, 11, 16, &|c| c.strike = Some(true));
set(&mut d, 17, 22, &|c| c.double_strike = Some(true));
let l = lay(&d);
let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default());
let link_of = |word: &str| {
items.iter().find_map(|i| if let display::Draw::Glyphs { text, link, .. } = i { text.contains(word).then(|| link.clone()) } else { None })
};
assert_eq!(link_of("Alpha"), Some(Some("https://a.example/".into())));
assert_eq!(link_of("Beta"), Some(Some("https://b.example/".into())));
let strokes: Vec<_> = items.iter().filter_map(|i| if let display::Draw::Line { stroke, .. } = i { Some(*stroke) } else { None }).collect();
assert!(strokes.contains(&display::Stroke::Solid) && strokes.contains(&display::Stroke::Double), "{strokes:?}");
// Underline colour is per run too.
let mut d = Document::from_text("Red Blue");
let red = wordcraft_doc::props::Rgb(0xFF, 0, 0);
let blue = wordcraft_doc::props::Rgb(0, 0, 0xFF);
for (a, b, c) in [(0, 3, red), (4, 8, blue)] {
d.format_range(&Pos::body(0, a), &Pos::body(0, b), &|p| {
p.underline = Some(wordcraft_doc::props::Underline::Single);
p.underline_color = Some(c);
})
.unwrap();
}
let l = lay(&d);
let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default());
let colors: Vec<_> = items.iter().filter_map(|i| if let display::Draw::Line { color, .. } = i { Some(*color) } else { None }).collect();
assert!(colors.contains(&red) && colors.contains(&blue), "{colors:?}");
}
#[test]
fn web_view_is_one_page() {
let d = Document::from_text(&"text ".repeat(3000));
let mut c = LayoutCache::new();
let l = layout(&d, &mut c, &LayoutOptions { view: ViewMode::Web, web_width: 800.0, ..Default::default() });
assert_eq!(l.pages.len(), 1);
assert!(l.pages[0].h > 800.0);
}
#[test]
fn hostile_props_do_not_panic() {
let mut d = Document::from_text("x\ny");
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(1, 0), &|p| {
*p = ParaProps { indent_left: Some(1e9), indent_right: Some(1e9), indent_first: Some(-1e9), ..Default::default() }
})
.unwrap();
d.last_section.page_w = 1.0;
d.last_section.margin_left = 500.0;
let l = lay(&d);
assert!(!l.pages.is_empty());
}
#[test]
fn layout_is_fast() {
let d =
Document::from_text(&"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.\n".repeat(3000));
let mut c = LayoutCache::new();
let t0 = now_ms();
let l = layout(&d, &mut c, &LayoutOptions::default());
let cold = now_ms() - t0;
let mut d2 = d.clone();
d2.insert_text(&Pos::body(1500, 0), "x", &Default::default()).unwrap();
let t1 = now_ms();
let _ = layout(&d2, &mut c, &LayoutOptions::default());
let warm = now_ms() - t1;
eprintln!("3000 paragraphs, {} pages: cold {cold:.1} ms, warm {warm:.1} ms", l.pages.len());
assert!(warm < cold);
}
#[test]
fn empty_center_tab_then_right_tab() {
let mut d = Document::from_text("left\t\tright");
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.style = Some("Header".into())).unwrap();
let l = lay(&d);
let end = l.caret(&Pos::body(0, 11)).unwrap().x;
assert!((end - (72.0 + 468.0)).abs() < 1.0, "{end}");
}
#[test]
fn footnotes_sit_at_page_bottom() {
let mut d = Document::from_text(&"Body text line.\n".repeat(30));
let mut note = wordcraft_doc::Paragraph::with_text("The note text.", 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(3, 4),
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() },
&Default::default(),
)
.unwrap();
let eid = d.add_part(
wordcraft_doc::PartKind::Endnote,
vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("An endnote.", Default::default()))],
);
d.insert_object(
&Pos::body(5, 2),
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Endnote, id: eid, custom: String::new() },
&Default::default(),
)
.unwrap();
let l = lay(&d);
let c = l.caret_on(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, 0).unwrap();
assert_eq!(c.page, 0);
assert!(c.top > 650.0 && c.top < 720.0, "{c:?}");
let body_bottom = l.pages[0]
.items
.iter()
.filter_map(|it| if let Placed::Lines { story: StoryRef::Body, y, para, l0, l1, .. } = it { item_bottom(*y, para, *l0, *l1) } else { None })
.fold(0.0f32, f32::max);
assert!(body_bottom < c.top, "{body_bottom} {}", c.top);
assert!(l.caret_on(&Pos { story: StoryRef::Part(eid), path: Path::top(0), off: 0 }, 0).is_some());
}
#[test]
fn deleted_note_anchors_take_no_number_and_print_no_note() {
use wordcraft_doc::para::NoteKind;
let mut d = Document::from_text("Alpha Beta Gamma");
let mut ids = Vec::new();
// Footnotes after "Alpha" and "Beta", an endnote after "Gamma" (inserted back to front).
for (off, kind, part) in [
(16, NoteKind::Endnote, wordcraft_doc::PartKind::Endnote),
(10, NoteKind::Footnote, wordcraft_doc::PartKind::Footnote),
(5, NoteKind::Footnote, wordcraft_doc::PartKind::Footnote),
] {
let id = d.add_part(part, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("A note.", Default::default()))]);
d.insert_object(&Pos::body(0, off), InlineObject::NoteRef { kind, id, custom: String::new() }, &Default::default()).unwrap();
ids.push(id);
}
let [end_id, second, first] = ids[..] else { panic!() };
// Track-delete the first footnote's anchor and the endnote's anchor.
let obj = wordcraft_doc::para::OBJ.len_utf8();
d.format_range(&Pos::body(0, 5), &Pos::body(0, 5 + obj), &|c| c.del = Some(0)).unwrap();
let end = d.para(StoryRef::Body, &Path::top(0)).unwrap().len();
d.format_range(&Pos::body(0, end - obj), &Pos::body(0, end), &|c| c.del = Some(0)).unwrap();
// The glyphs of each note mark in the body, and whether the endnote's text is placed.
let marks = |hide_deleted: bool| {
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { hide_deleted, ..Default::default() });
let items: Vec<&Placed> = l.pages.iter().flat_map(|p| p.items.iter()).collect();
let mut marks = HashMap::new();
for it in &items {
if let Placed::Lines { story: StoryRef::Body, para, .. } = it {
for (ci, id) in &para.notes {
let c = &para.clusters[*ci];
marks.insert(*id, para.glyphs[c.g0 as usize..c.g1 as usize].iter().map(|g| g.gid).collect::<Vec<_>>());
}
}
}
let endnote = items.iter().any(|i| matches!(i, Placed::Lines { story: StoryRef::Part(p), .. } if *p == end_id));
(marks, endnote)
};
// Markup: footnotes 1 and 2, and the endnote prints.
let (full, endnote) = marks(false);
assert!(full[&first] != full[&second] && endnote, "{full:?}");
// Final text: the surviving footnote is number 1, and the deleted endnote anchor prints no note.
let (fin, endnote) = marks(true);
assert_eq!(fin.get(&second), full.get(&first), "the surviving footnote keeps number 2");
assert!(!endnote, "the endnote of a deleted anchor is printed");
}
#[test]
fn hidden_note_anchors_keep_their_number_and_their_note() {
// Measured in Word (hidden text not printed): a hidden footnote or endnote reference mark still
// takes its number, so the visible footnote after it is 2, and both notes are still printed.
use wordcraft_doc::para::NoteKind;
let mut d = Document::from_text("Alpha Beta Gamma");
let mut ids = Vec::new();
for (off, kind, part) in [
(16, NoteKind::Endnote, wordcraft_doc::PartKind::Endnote),
(10, NoteKind::Footnote, wordcraft_doc::PartKind::Footnote),
(5, NoteKind::Footnote, wordcraft_doc::PartKind::Footnote),
] {
let id = d.add_part(part, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("A note.", Default::default()))]);
d.insert_object(&Pos::body(0, off), InlineObject::NoteRef { kind, id, custom: String::new() }, &Default::default()).unwrap();
ids.push(id);
}
let [end_id, second, first] = ids[..] else { panic!() };
// Hide the first footnote's anchor and the endnote's anchor.
let obj = wordcraft_doc::para::OBJ.len_utf8();
d.format_range(&Pos::body(0, 5), &Pos::body(0, 5 + obj), &|c| c.hidden = Some(true)).unwrap();
let end = d.para(StoryRef::Body, &Path::top(0)).unwrap().len();
d.format_range(&Pos::body(0, end - obj), &Pos::body(0, end), &|c| c.hidden = Some(true)).unwrap();
// The glyphs of each visible note mark in the body, and which notes' text is placed.
let marks = |show_hidden: bool| {
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() });
let items: Vec<&Placed> = l.pages.iter().flat_map(|p| p.items.iter()).collect();
let mut marks = HashMap::new();
for it in &items {
if let Placed::Lines { story: StoryRef::Body, para, .. } = it {
for (ci, id) in &para.notes {
let c = &para.clusters[*ci];
marks.insert(*id, para.glyphs[c.g0 as usize..c.g1 as usize].iter().map(|g| g.gid).collect::<Vec<_>>());
}
}
}
let printed = |id: u32| items.iter().any(|i| matches!(i, Placed::Lines { story: StoryRef::Part(p), .. } if *p == id));
(marks, [first, second, end_id].map(printed))
};
let (shown, printed) = marks(true);
assert!(shown[&first] != shown[&second] && printed == [true; 3], "{shown:?} {printed:?}");
// Not shown: the visible footnote is still number 2, and every note is printed.
let (fin, printed) = marks(false);
assert_eq!(fin.get(&second), shown.get(&second), "the visible footnote is renumbered");
assert_eq!(printed, [true; 3], "a note whose reference is hidden is not printed");
}
#[test]
fn text_wraps_around_square_float() {
let mut d = Document::from_text(&"Words flow around the picture here. ".repeat(30));
let float = wordcraft_doc::para::Float {
wrap: wordcraft_doc::para::Wrap::Square,
h_rel: wordcraft_doc::para::Anchor::Column,
v_rel: wordcraft_doc::para::Anchor::Paragraph,
x: 0.0,
y: 0.0,
dist: 9.0,
..Default::default()
};
let shape = InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w: 144.0,
h: 100.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let l = lay(&d);
let Placed::Lines { para, x, .. } = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })).unwrap() else { panic!() };
let first = &para.lines[0];
assert!(first.left >= 144.0, "first line starts beside the float: {}", first.left);
let later = para.lines.iter().find(|ln| ln.top > 120.0).unwrap();
assert!(later.left < 1.0, "lines below the float use the full width: {}", later.left);
assert!(x + first.left > 72.0 + 144.0);
assert!(l.pages[0].items.iter().any(|i| matches!(i, Placed::Shape { .. })));
}
#[test]
fn deleted_float_leaves_no_wrap_area_without_markup() {
// A square-wrapped shape anchored at the start of the paragraph, its anchor track-deleted.
let text = "Words flow around the picture here. ".repeat(30);
let mut d = Document::from_text(&text);
let float = wordcraft_doc::para::Float {
wrap: wordcraft_doc::para::Wrap::Square,
h_rel: wordcraft_doc::para::Anchor::Column,
v_rel: wordcraft_doc::para::Anchor::Paragraph,
x: 0.0,
y: 0.0,
dist: 9.0,
dist_top: 9.0,
dist_bottom: 9.0,
..Default::default()
};
let shape = InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w: 144.0,
h: 100.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let obj = wordcraft_doc::para::OBJ.len_utf8();
d.format_range(&Pos::body(0, 0), &Pos::body(0, obj), &|c| c.del = Some(0)).unwrap();
// Where the first line starts, and whether the shape is placed.
let first = |hide_deleted: bool| {
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { hide_deleted, ..Default::default() });
let p = &l.pages[0];
let Some(Placed::Lines { para, .. }) = p.items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
(para.lines[0].left, p.items.iter().any(|i| matches!(i, Placed::Shape { .. })))
};
// With markup the deleted shape is shown and the text flows beside it.
let (left, shown) = first(false);
assert!(left >= 144.0 && shown, "{left} {shown}");
// Final text: no shape, and no gap where it was.
assert_eq!(first(true), (0.0, false));
}
#[test]
fn hidden_float_leaves_no_wrap_area_when_hidden_text_is_not_shown() {
// A square-wrapped shape whose anchor is hidden text, through a character style (so only the
// resolved formatting says so). Measured in Word: not printed, and the text runs full width.
let text = "Words flow around the picture here. ".repeat(30);
let mut d = Document::from_text(&text);
d.styles.upsert(wordcraft_doc::Style {
id: "Secret".into(),
name: "Secret".into(),
kind: wordcraft_doc::StyleKind::Character,
chr: wordcraft_doc::CharProps { hidden: Some(true), ..Default::default() },
..Default::default()
});
let float = wordcraft_doc::para::Float {
wrap: wordcraft_doc::para::Wrap::Square,
h_rel: wordcraft_doc::para::Anchor::Column,
v_rel: wordcraft_doc::para::Anchor::Paragraph,
x: 0.0,
y: 0.0,
dist: 9.0,
dist_top: 9.0,
dist_bottom: 9.0,
..Default::default()
};
let shape = InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w: 144.0,
h: 100.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let obj = wordcraft_doc::para::OBJ.len_utf8();
d.format_range(&Pos::body(0, 0), &Pos::body(0, obj), &|c| c.style = Some("Secret".into())).unwrap();
// Where the first line starts, and whether the shape is placed.
let first = |show_hidden: bool| {
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() });
let p = &l.pages[0];
let Some(Placed::Lines { para, .. }) = p.items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
(para.lines[0].left, p.items.iter().any(|i| matches!(i, Placed::Shape { .. })))
};
// Hidden text shown: the shape is placed and the text flows beside it.
let (left, shown) = first(true);
assert!(left >= 144.0 && shown, "{left} {shown}");
// Not shown: no shape, and no gap where it was.
assert_eq!(first(false), (0.0, false));
}
/// A page-number start or a line-number start near `u32::MAX` (accepted from files and commands)
/// overflowed when the next page or line was counted: a panic with overflow checks on.
#[test]
fn extreme_page_and_line_number_starts_do_not_overflow() {
let mut d = Document::from_text("one\ntwo\nthree");
for b in d.body.iter_mut() {
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) {
p.props.page_break_before = Some(true);
}
}
d.last_section.page_num_start = Some(u32::MAX);
d.last_section.line_numbers = Some(wordcraft_doc::section::LineNumbering { start: u32::MAX, ..Default::default() });
let l = lay(&d);
assert!(l.pages.len() >= 3, "{} pages", l.pages.len());
}
#[test]
fn line_numbers_borders_text_boxes() {
let mut d = Document::from_text("one\ntwo\nthree");
d.last_section.line_numbers = Some(Default::default());
d.last_section.page_borders = Some(wordcraft_doc::props::Borders::box_(wordcraft_doc::props::Border::single(1.0)));
let id = d.add_part(
wordcraft_doc::PartKind::TextBox,
vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("inside the box", Default::default()))],
);
let tb = InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::TextBox,
w: 144.0,
h: 72.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float: Default::default(),
story: Some(id),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
d.insert_object(&Pos::body(2, 5), tb, &Default::default()).unwrap();
let l = lay(&d);
let p = &l.pages[0];
let numbers = p.items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(u32::MAX), .. })).count();
assert_eq!(numbers, 3);
assert!(p.items.iter().filter(|i| matches!(i, Placed::Rule { .. })).count() >= 4);
assert!(l.caret(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }).is_some());
}
#[test]
fn tall_rows_split_across_pages() {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(2, 2, 468.0);
let long = "Row text that keeps going and going so the cell grows taller than a page. ".repeat(120);
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&long, Default::default()))];
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t.clone())).unwrap();
let l = lay(&d);
assert!(l.pages.len() >= 2, "pages {}", l.pages.len());
// The cell's lines start on page 1 (row split, not moved) and continue on page 2.
let cell_lines = |p: &Page| p.items.iter().filter(|i| matches!(i, Placed::Lines { path, .. } if path.0 == vec![1, 0, 0, 0])).count();
assert!(cell_lines(&l.pages[0]) > 0);
assert!(cell_lines(&l.pages[1]) > 0);
// Nothing runs past the bottom margin.
for p in &l.pages[..l.pages.len() - 1] {
for it in &p.items {
if let Placed::Lines { y, para, l0, l1, .. } = it {
let b = item_bottom(*y, para, *l0, *l1).unwrap_or(0.0);
assert!(b <= 792.0 - 72.0 + 0.5, "line bottom {b}");
}
}
}
// Every line of the long paragraph is placed exactly once.
let total: usize = l
.pages
.iter()
.flat_map(|p| p.items.iter())
.filter_map(|i| if let Placed::Lines { path, l0, l1, .. } = i { (path.0 == vec![1, 0, 0, 0]).then_some(l1 - l0) } else { None })
.sum();
let n = l
.pages
.iter()
.flat_map(|p| p.items.iter())
.find_map(|i| if let Placed::Lines { path, para, .. } = i { (path.0 == vec![1, 0, 0, 0]).then_some(para.lines.len()) } else { None })
.unwrap_or(0);
assert_eq!(total, n);
// "Can't split" rows move whole instead.
let mut d2 = Document::from_text("before\nafter");
t.rows[0].props.cant_split = true;
d2.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l2 = lay(&d2);
assert!(l2.pages.len() >= 2);
}
#[test]
fn repeated_header_is_followed_by_the_next_row_even_when_it_overflows() {
// A one-column table at the top of a letter page (body 648 pt), then an empty paragraph. Returns
// the table rows on each page, in order.
let rows_per_page = |t: Table| -> Vec<Vec<usize>> {
let mut d = Document::new();
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
l.pages
.iter()
.map(|p| p.items.iter().filter_map(|i| if let Placed::Cell { row, cell: 0, .. } = i { Some(*row) } else { None }).collect())
.collect()
};
// A header row of `header` points and body rows of `rows` points (at least), one line of text each.
let table = |header: f32, rows: &[f32]| {
let mut t = Table::new(rows.len() + 1, 1, 468.0);
t.rows[0].props.header = true;
for (row, h) in t.rows.iter_mut().zip(std::iter::once(&header).chain(rows)) {
row.props.height = Some(*h);
row.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast;
}
t
};
// Header and row fit together: one row per page, under the header.
assert_eq!(rows_per_page(table(100.0, &[400.0; 3])), vec![vec![0, 1], vec![0, 2], vec![0, 3]]);
// A 300 pt header and 400 pt rows never fit together. As in Word, the first row moves to a new
// page, goes under the repeated header and runs into the bottom margin; so does every later row.
assert_eq!(rows_per_page(table(300.0, &[400.0; 4])), vec![vec![0], vec![0, 1], vec![0, 2], vec![0, 3], vec![0, 4], vec![]]);
// A header that almost fills the page still repeats, with one row under it on each page.
assert_eq!(rows_per_page(table(640.0, &[100.0; 3])), vec![vec![0], vec![0, 1], vec![0, 2], vec![0, 3], vec![]]);
// A 20-row table under a 640 pt header: one page per row, never a runaway.
assert_eq!(rows_per_page(table(640.0, &[12.0; 19])).len(), 21);
// A row of text that is taller than the room under the header still splits across pages.
let mut t = table(100.0, &[12.0]);
let long = "Row text that keeps going and going so the cell grows taller than a page. ".repeat(120);
t.rows[1].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&long, Default::default()))];
let pages = rows_per_page(t);
assert!(pages.len() >= 3, "{pages:?}");
assert!(pages.iter().all(|p| p.first() == Some(&0) && p.contains(&1)), "{pages:?}");
// An ordinary header still repeats on every page the table reaches.
let mut t = Table::new(80, 2, 468.0);
t.rows[0].props.header = true;
let pages = rows_per_page(t);
assert!(pages.len() >= 2, "{pages:?}");
assert!(pages.iter().all(|p| p.first() == Some(&0)), "{pages:?}");
}
/// A drop-cap paragraph in a font larger than 1000 pt used to panic in layout (`clamp` with min > max).
#[test]
fn drop_cap_with_a_huge_font_lays_out() {
for size in [999.0, 1000.0, 1001.0, 1638.0] {
let cp = wordcraft_doc::CharProps { size: Some(size), ..Default::default() };
let mut p = wordcraft_doc::Paragraph::with_text("Hello drop cap world", cp);
p.props.drop_cap = Some(3);
let mut d = Document::from_text("x");
d.body = vec![wordcraft_doc::para_block(p)];
let l = lay(&d);
let Some(pl) = l.pages[0].items.iter().find_map(|i| if let Placed::Lines { para, .. } = i { Some(para.clone()) } else { None }) else {
panic!("no lines at size {size}")
};
let (nc, lines, _) = pl.drop_cap.unwrap_or((0, 0, 0.0));
assert_eq!((nc, lines), (1, 3), "size {size}");
}
}
#[test]
fn drop_cap_indents_its_lines() {
let mut p =
wordcraft_doc::Paragraph::with_text(&"Every line of this paragraph wraps around a large first letter. ".repeat(6), Default::default());
p.props.drop_cap = Some(3);
let mut d = Document::from_text("x");
d.body = vec![wordcraft_doc::para_block(p)];
let l = lay(&d);
let Some(pl) = l.pages[0].items.iter().find_map(|i| if let Placed::Lines { para, .. } = i { Some(para.clone()) } else { None }) else {
panic!("no lines")
};
let (nc, lines, w) = pl.drop_cap.unwrap_or((0, 0, 0.0));
assert_eq!((nc, lines), (1, 3));
assert!(w > 20.0, "drop cap width {w}");
assert!(pl.lines.len() > 4);
for (k, line) in pl.lines.iter().enumerate() {
let x = line.xs.first().copied().unwrap_or(0.0);
if k < 3 {
assert!(x >= w - 0.5, "line {k} at {x}");
} else {
assert!(x < 1.0, "line {k} at {x}");
}
}
// The letter's glyphs are lowered toward the third line's baseline.
let g = pl.glyphs.first().map(|g| g.dy).unwrap_or(0.0);
assert!(g < -20.0, "dy {g}");
}
#[test]
fn auto_hyphenation_breaks_long_words() {
let text = "Internationalization considerations notwithstanding, administrators systematically reconsidered extraordinarily uncharacteristic responsibilities. ".repeat(4);
let mut d = Document::from_text(&text);
let off = lay(&d);
let hyphens = |l: &DocLayout| {
l.pages[0]
.items
.iter()
.filter_map(|i| if let Placed::Lines { para, .. } = i { Some(para.lines.iter().filter(|x| x.hyphen.is_some()).count()) } else { None })
.sum::<usize>()
};
assert_eq!(hyphens(&off), 0);
d.settings.auto_hyphenation = true;
let on = lay(&d);
assert!(hyphens(&on) > 0, "no hyphenated lines");
// Hyphenated lines stay inside the margins.
for it in &on.pages[0].items {
if let Placed::Lines { para, .. } = it {
for l in para.lines.iter().filter(|l| l.hyphen.is_some()) {
assert!(l.xs.last().copied().unwrap_or(0.0) <= l.right + 0.5, "{} > {}", l.xs.last().copied().unwrap_or(0.0), l.right);
}
}
}
// Soft hyphens break even without auto hyphenation, and show a hyphen.
let soft = format!("{}extra\u{ad}ordinary", "word ".repeat(13));
let d2 = Document::from_text(&soft);
let l2 = lay(&d2);
let _ = hyphens(&l2);
}
/// Hyphenation Options (#407): a consecutive-hyphen limit of 1 never lets two lines in a row end
/// with a hyphen, and words in capitals stay whole when the document says so.
#[test]
fn hyphenation_options_limit_hyphens_and_spare_capitals() {
let text = "Internationalization considerations notwithstanding, administrators systematically reconsidered extraordinarily uncharacteristic responsibilities. ".repeat(6);
let lines = |d: &Document| -> Vec<bool> {
lay(d).pages[0]
.items
.iter()
.filter_map(|i| {
if let Placed::Lines { para, .. } = i { Some(para.lines.iter().map(|x| x.hyphen.is_some()).collect::<Vec<_>>()) } else { None }
})
.flatten()
.collect()
};
let mut d = Document::from_text(&text);
d.settings.auto_hyphenation = true;
d.settings.consecutive_hyphen_limit = 1;
let hy = lines(&d);
assert!(hy.windows(2).all(|w| !(w[0] && w[1])), "two hyphenated lines in a row: {hy:?}");
let mut caps = Document::from_text(&text.to_uppercase());
caps.settings.auto_hyphenation = true;
caps.settings.hyphenate_caps = false;
assert!(lines(&caps).iter().all(|h| !h), "a word in capitals was hyphenated");
}
/// A table of `rows` x 2 with text in every cell, after a "before" paragraph.
fn styled_table_doc(style: Option<&str>, rows: usize) -> Document {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(rows, 2, 468.0);
t.props.style = style.map(str::to_string);
for (r, row) in t.rows.iter_mut().enumerate() {
for (c, cell) in row.cells.iter_mut().enumerate() {
cell.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("R{r} C{c}"), Default::default()))];
}
}
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
d
}
fn row_height(l: &DocLayout, row: usize) -> f32 {
l.pages[0].items.iter().find_map(|i| if let Placed::Cell { rect, row: r, .. } = i { (*r == row).then_some(rect.h) } else { None }).unwrap()
}
/// The laid-out paragraph in cell (row, col) of the table at body index 1.
fn cell_para(l: &DocLayout, row: u32, col: u32) -> Arc<ParaLayout> {
l.pages[0]
.items
.iter()
.find_map(|i| if let Placed::Lines { path, para, .. } = i { (path.0 == vec![1, row, col, 0]).then(|| para.clone()) } else { None })
.unwrap()
}
/// Text in a table cell takes its table style's paragraph and run formatting over the document
/// defaults (ECMA-376 §17.7.2): Table Grid's single spacing and no space after, not the defaults'
/// 8 pt after and 1.15 lines.
#[test]
fn table_style_paragraph_props_beat_document_defaults() {
let d = styled_table_doc(Some("TableGrid"), 3);
let plain = styled_table_doc(None, 3);
let (lg, lp) = (lay(&d), lay(&plain));
let p = cell_para(&lg, 1, 0);
assert_eq!((p.rp.space_after, p.rp.line_spacing), (0.0, wordcraft_doc::props::LineSpacing::Multiple(1.0)));
let q = cell_para(&lp, 1, 0);
assert_eq!((q.rp.space_after, q.rp.line_spacing), (8.0, wordcraft_doc::props::LineSpacing::Multiple(1.15)));
let (g, n) = (row_height(&lg, 1), row_height(&lp, 1));
assert!(g + 8.0 < n, "Table Grid row {g}, unstyled row {n}");
assert!(g < 18.0, "one 12 pt line, single spaced: {g}");
}
/// The paragraph's own style (Normal included) and direct formatting beat the table style; the
/// table style only fills in what they leave unset.
#[test]
fn paragraph_style_and_direct_formatting_beat_table_style() {
let mut d = styled_table_doc(Some("TableGrid"), 3);
d.styles.upsert(wordcraft_doc::Style {
id: "Spaced".into(),
name: "Spaced".into(),
based_on: Some("Normal".into()),
para: ParaProps { space_after: Some(12.0), ..Default::default() },
..Default::default()
});
d.format_paragraphs(
&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 },
&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 },
&|p| p.style = Some("Spaced".into()),
)
.unwrap();
d.format_paragraphs(
&Pos { story: StoryRef::Body, path: Path(vec![1, 1, 0, 0]), off: 0 },
&Pos { story: StoryRef::Body, path: Path(vec![1, 1, 0, 0]), off: 0 },
&|p| p.space_after = Some(3.0),
)
.unwrap();
let l = lay(&d);
let styled = cell_para(&l, 0, 0);
assert_eq!(styled.rp.space_after, 12.0, "paragraph style");
assert_eq!(styled.rp.line_spacing, wordcraft_doc::props::LineSpacing::Multiple(1.0), "unset by the style: from the table style");
assert_eq!(cell_para(&l, 1, 0).rp.space_after, 3.0, "direct formatting");
assert_eq!(cell_para(&l, 2, 0).rp.space_after, 0.0, "table style");
// Normal setting its own spacing wins too, even where it equals nothing in the defaults.
if let Some(n) = d.styles.get_mut("Normal") {
n.para.space_after = Some(10.0);
}
assert_eq!(cell_para(&lay(&d), 2, 0).rp.space_after, 10.0, "Normal");
}
/// A table style based on another keeps the base style's borders and cell text formatting, and
/// adds its own run formatting and conditional formats.
#[test]
fn derived_table_style_merges_its_base() {
use wordcraft_doc::styles::TableStyleParts;
let red = wordcraft_doc::Rgb(0xC0, 0, 0);
let mut d = styled_table_doc(Some("RedGrid"), 3);
d.styles.upsert(wordcraft_doc::Style {
id: "RedGrid".into(),
name: "Red Grid".into(),
kind: wordcraft_doc::StyleKind::Table,
based_on: Some("TableGrid".into()),
chr: CharProps { bold: Some(true), color: Some(wordcraft_doc::TextColor::Rgb(red)), ..Default::default() },
table: Some(TableStyleParts { header_chr: CharProps { italic: Some(true), ..Default::default() }, ..Default::default() }),
..Default::default()
});
let l = lay(&d);
let rules = l.pages[0].items.iter().filter(|i| matches!(i, Placed::Rule { .. })).count();
assert!(rules >= 18, "Table Grid's borders: {rules}");
let body = cell_para(&l, 1, 1);
let rc = &body.styles[0].rc;
assert!(rc.bold && !rc.italic, "{rc:?}");
assert_eq!(rc.color, wordcraft_doc::TextColor::Rgb(red));
assert_eq!(body.rp.space_after, 0.0, "Table Grid's paragraph formatting");
let head = &cell_para(&l, 0, 0).styles[0].rc;
assert!(head.bold && head.italic, "{head:?}");
// Direct formatting still wins over the table style.
let mut d2 = d.clone();
let path = Path(vec![1, 2, 0, 0]);
d2.format_range(&Pos { story: StoryRef::Body, path: path.clone(), off: 0 }, &Pos { story: StoryRef::Body, path, off: 5 }, &|c| {
c.bold = Some(false)
})
.unwrap();
assert!(!cell_para(&lay(&d2), 2, 0).styles[0].rc.bold);
}
fn text_box(d: &mut Document, at: usize, text: &str, w: f32, h: f32, float: wordcraft_doc::para::Float) -> u32 {
text_box_at(d, &Pos::body(0, at), text, w, h, float)
}
fn text_box_at(d: &mut Document, pos: &Pos, text: &str, w: f32, h: f32, float: wordcraft_doc::para::Float) -> u32 {
let id =
d.add_part(wordcraft_doc::PartKind::TextBox, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))]);
let shape = InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::TextBox,
w,
h,
fill: None,
stroke: None,
stroke_width: 0.75,
float,
story: Some(id),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
d.insert_object(pos, shape, &Default::default()).unwrap();
id
}
fn box_rect(l: &DocLayout, id: u32) -> wordcraft_geom::Rect {
l.pages[0]
.items
.iter()
.find_map(|i| if let Placed::Object { rect, text_box, .. } = i { (*text_box == Some(id)).then_some(*rect) } else { None })
.unwrap()
}
#[test]
fn clicks_inside_a_text_box_hit_its_story() {
let mut d = Document::from_text("Before after");
let id = text_box(&mut d, 7, "Inside the box", 144.0, 72.0, Default::default());
let l = lay(&d);
let r = box_rect(&l, id);
assert!((r.w - 144.0).abs() < 0.5 && (r.h - 72.0).abs() < 0.5, "{r:?}");
let box_story = StoryRef::Part(id);
// Anywhere inside the box, even below its text, is the box.
for (x, y) in [(r.x + 20.0, r.y + 10.0), (r.x + r.w - 4.0, r.y + r.h - 4.0)] {
assert_eq!(l.story_at(0, x, y), Some(box_story), "({x}, {y}) in {r:?}");
assert_eq!(l.hit(0, x, y, box_story).map(|p| p.story), Some(box_story));
}
// The body text beside it is the body; so is the margin just right of the box, although the
// box's own lines are within reach there.
let c = l.caret(&Pos::body(0, 2)).unwrap();
assert_eq!(l.story_at(0, c.x, c.top + c.height / 2.0), Some(StoryRef::Body));
assert_ne!(l.story_at(0, r.right() + 8.0, r.y + 10.0), Some(box_story));
// The caret in the box sits inside it.
let bc = l.caret(&Pos { story: box_story, path: Path::top(0), off: 0 }).unwrap();
assert!(r.contains(wordcraft_geom::Point::new(bc.x, bc.top)), "{bc:?} outside {r:?}");
}
#[test]
fn body_text_wins_over_a_text_box_behind_it() {
let mut d = Document::from_text("Hi");
let float = wordcraft_doc::para::Float {
wrap: wordcraft_doc::para::Wrap::BehindText,
h_rel: wordcraft_doc::para::Anchor::Column,
v_rel: wordcraft_doc::para::Anchor::Paragraph,
x: 0.0,
y: 0.0,
dist: 0.0,
..Default::default()
};
let id = text_box(&mut d, 0, "Behind", 200.0, 150.0, float);
let l = lay(&d);
let r = box_rect(&l, id);
let c = l.caret(&Pos::body(0, 0)).unwrap();
assert!(r.contains(wordcraft_geom::Point::new(c.x + 2.0, c.top + 2.0)), "box covers the text: {r:?} {c:?}");
assert_eq!(l.story_at(0, c.x + 2.0, c.top + c.height / 2.0), Some(StoryRef::Body));
assert_eq!(l.story_at(0, r.right() - 10.0, r.bottom() - 10.0), Some(StoryRef::Part(id)));
}
#[test]
fn presses_grab_pictures_anywhere_and_text_boxes_by_their_border() {
let mut d = Document::from_text("Words before the objects.");
let id = text_box(&mut d, 0, "Inside", 144.0, 72.0, Default::default());
let shape = InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w: 60.0,
h: 40.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float: Default::default(),
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let l = lay(&d);
let b = box_rect(&l, id);
// The text box: its border grabs it, inside is its text.
let edge = l.object_at(0, b.x + 1.0, b.y + b.h / 2.0, 4.0).unwrap();
assert_eq!(edge.text_box, Some(id));
assert_eq!(l.object(&edge.pos(), 0).map(|o| o.rect), Some(b));
assert_eq!(l.text_box(id, 5).map(|o| o.rect), Some(b), "a bad hint still finds it");
assert!(l.object_at(0, b.x + b.w / 2.0, b.y + b.h / 2.0, 4.0).is_none());
// The shape: anywhere on it.
let s = l.find_object(0, |o| o.text_box.is_none()).unwrap();
let hit = l.object_at(0, s.rect.x + s.rect.w / 2.0, s.rect.y + s.rect.h / 2.0, 4.0).unwrap();
assert_eq!((hit.off, hit.text_box, hit.floating()), (0, None, false));
// Plain text: nothing.
let c = l.caret(&Pos::body(0, 10)).unwrap();
assert!(l.object_at(0, c.x, c.top + 2.0, 4.0).is_none());
}
/// (lines placed, lines laid out) of a text box's story, and its area.
fn box_lines(l: &DocLayout, id: u32) -> (usize, usize, wordcraft_geom::Rect) {
let mut shown = 0;
let mut total = 0;
for it in &l.pages[0].items {
if let Placed::Lines { story: StoryRef::Part(p), para, l0, l1, y, .. } = it
&& *p == id
{
shown += l1 - l0;
total += para.lines.len();
let first = &para.lines[*l0];
let last = &para.lines[l1 - 1];
let bottom = y + last.top + last.height - first.top;
let r = box_rect(l, id);
assert!(bottom <= r.bottom() + 0.5 || shown == 1, "a placed line overflows: {bottom} > {}", r.bottom());
}
}
(shown, total, box_rect(l, id))
}
#[test]
fn text_box_hides_text_that_does_not_fit() {
let long = "The quick brown fox jumps over the lazy dog. ".repeat(12);
let mut d = Document::from_text("Body");
let id = text_box(&mut d, 0, &long, 144.0, 72.0, Default::default());
let (shown, total, r) = box_lines(&lay(&d), id);
assert!(shown >= 2 && shown < total, "{shown} of {total} lines shown in {r:?}");
// Nothing below the box belongs to it.
let l = lay(&d);
assert_ne!(l.story_at(0, r.x + 20.0, r.bottom() + 6.0), Some(StoryRef::Part(id)));
// A taller box shows more; a tiny one still shows its first line.
let mut d2 = Document::from_text("Body");
let id2 = text_box(&mut d2, 0, &long, 144.0, 1500.0, Default::default());
let (shown2, total2, _) = box_lines(&lay(&d2), id2);
assert_eq!(shown2, total2);
let mut d3 = Document::from_text("Body");
let id3 = text_box(&mut d3, 0, &long, 144.0, 18.0, Default::default());
assert_eq!(box_lines(&lay(&d3), id3).0, 1);
}
#[test]
fn square_wrap_flows_text_on_both_sides() {
let float = |x: f32| wordcraft_doc::para::Float {
wrap: wordcraft_doc::para::Wrap::Square,
h_rel: wordcraft_doc::para::Anchor::Column,
v_rel: wordcraft_doc::para::Anchor::Paragraph,
x,
y: 0.0,
dist: 9.0,
..Default::default()
};
let text = "Words flow on both sides of the box in the middle here. ".repeat(20);
let mut d = Document::from_text(&text);
let id = text_box(&mut d, 0, "Middle", 144.0, 100.0, float(160.0));
let l = lay(&d);
let r = box_rect(&l, id);
let Placed::Lines { para, x, y, .. } = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { story: StoryRef::Body, .. })).unwrap() else {
panic!()
};
let beside: Vec<&para::Line> = para.lines.iter().filter(|ln| ln.beside).collect();
assert!(beside.len() >= 4, "{} rows have text on the far side", beside.len());
for (k, ln) in para.lines.iter().enumerate().filter(|(_, ln)| ln.beside) {
let prev = &para.lines[k - 1];
assert_eq!((prev.top, prev.height), (ln.top, ln.height), "one row");
assert!(x + ink_end(para, prev) <= r.x + 0.01, "left part stops before the box");
assert!(x + ln.xs[0] >= r.right(), "right part starts after it");
assert_eq!(prev.stop, ln.start, "text runs left part, then right part");
}
// Below the box: full-width rows again.
let below = para.lines.iter().find(|ln| y + ln.top > r.bottom() + 10.0).unwrap();
assert!(!below.beside && below.right - below.left > 400.0);
// The right-hand part is hittable and has a caret.
let ln = beside[0];
let pos = l.hit(0, x + ln.xs[1], y + ln.top + 2.0, StoryRef::Body).unwrap();
assert!(pos.off >= ln.start && pos.off <= ln.stop, "{pos:?}");
assert!(l.caret(&pos).unwrap().x >= r.right());
// A gap too narrow for text stays empty.
let mut d2 = Document::from_text(&text);
let id2 = text_box(&mut d2, 0, "Edge", 144.0, 100.0, float(468.0 - 144.0 - 20.0));
let l2 = lay(&d2);
let r2 = box_rect(&l2, id2);
let Placed::Lines { para, x, .. } = l2.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { story: StoryRef::Body, .. })).unwrap() else {
panic!()
};
assert!(para.lines.iter().all(|ln| !ln.beside && ink_end(para, ln) <= (r2.x - x).max(ln.right) + 0.01));
}
/// Where a line's text ends (its last non-space cluster's right edge; trailing spaces may hang
/// past the margin), relative to the column.
fn ink_end(pl: &ParaLayout, ln: &para::Line) -> f32 {
(ln.c0..ln.c1)
.filter_map(|k| {
let c = pl.clusters.get(k)?;
(c.kind != para::ClKind::Space).then_some(ln.xs.get(k - ln.c0)? + c.adv)
})
.fold(0.0, f32::max)
}
fn footnote(d: &mut Document, pos: &Pos, text: &str) -> u32 {
let id =
d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))]);
d.insert_object(pos, InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() }, &Default::default())
.unwrap();
id
}
fn page_float(x: f32, y: f32) -> wordcraft_doc::para::Float {
wordcraft_doc::para::Float {
wrap: wordcraft_doc::para::Wrap::InFrontOfText,
h_rel: wordcraft_doc::para::Anchor::Page,
v_rel: wordcraft_doc::para::Anchor::Page,
x,
y,
dist: 0.0,
..Default::default()
}
}
#[test]
fn text_boxes_show_in_headers_footers_cells_and_notes() {
let mut d = Document::from_text(&"Body paragraph.\n".repeat(4));
// Header: an inline box and a box placed on the page.
let hid =
d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Head", Default::default()))]);
d.last_section.headers.default = Some(hid);
let e = d.end_of(StoryRef::Part(hid));
let inline = text_box_at(&mut d, &e, "Inline in header", 100.0, 30.0, Default::default());
let e = d.end_of(StoryRef::Part(hid));
let placed = text_box_at(&mut d, &e, "On the page", 100.0, 40.0, page_float(400.0, 20.0));
// Footer: a box placed on the page (the footer is laid out twice to find it).
let fid =
d.add_part(wordcraft_doc::PartKind::Footer, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Foot", Default::default()))]);
d.last_section.footers.default = Some(fid);
let e = d.end_of(StoryRef::Part(fid));
let in_footer = text_box_at(&mut d, &e, "Footer box", 100.0, 30.0, page_float(300.0, 740.0));
// A table cell and a footnote.
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(wordcraft_doc::Table::new(1, 1, 300.0))).unwrap();
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 };
let in_cell = text_box_at(&mut d, &cell, "In a cell", 120.0, 30.0, Default::default());
let note = footnote(&mut d, &Pos::body(0, 4), "Note: ");
let e = d.end_of(StoryRef::Part(note));
let in_note = text_box_at(&mut d, &e, "In a note", 120.0, 30.0, Default::default());
let l = lay(&d);
let p = &l.pages[0];
let shows =
|id: u32| p.header.iter().chain(&p.footer).chain(&p.items).any(|i| matches!(i, Placed::Lines { story: StoryRef::Part(s), .. } if *s == id));
for (name, id) in
[("inline header box", inline), ("page header box", placed), ("footer box", in_footer), ("cell box", in_cell), ("note box", in_note)]
{
assert!(shows(id), "{name}: no text");
assert!(l.caret(&d.start_of(StoryRef::Part(id))).is_some(), "{name}: no caret");
}
// Page-anchored boxes land where they say, header and footer alike; their shapes are drawn.
assert_eq!(l.text_box(placed, 0).map(|o| (o.rect.x, o.rect.y)), Some((400.0, 20.0)));
assert_eq!(l.text_box(in_footer, 0).map(|o| (o.rect.x, o.rect.y)), Some((300.0, 740.0)));
assert!(p.header.iter().any(|i| matches!(i, Placed::Shape { rect, .. } if rect.x == 400.0)));
// Header boxes are found for clicks while editing the header.
let r = l.text_box(inline, 0).unwrap().rect;
assert_eq!(l.header_footer_text_box_at(0, r.x + 5.0, r.y + 5.0), Some(inline));
}
#[test]
fn footnotes_inside_text_boxes_are_numbered_and_placed() {
let mut d = Document::from_text(&"Body text line.\n".repeat(30));
let id = text_box_at(&mut d, &Pos::body(2, 0), "Box text", 144.0, 40.0, Default::default());
let e = d.end_of(StoryRef::Part(id));
let in_box = footnote(&mut d, &e, "Note from the box.");
let after = footnote(&mut d, &Pos::body(5, 4), "Note from the body.");
let l = lay(&d);
// Both notes are at the bottom of page 1, the box's first (reading order).
let a = l.caret(&d.start_of(StoryRef::Part(in_box))).expect("the box's footnote is placed");
let b = l.caret(&d.start_of(StoryRef::Part(after))).unwrap();
assert_eq!((a.page, b.page), (0, 0));
assert!(a.top > 600.0 && a.top < b.top, "{a:?} {b:?}");
let nums = note_numbers(&d, false);
assert_eq!((nums.get(&in_box), nums.get(&after)), (Some(&1), Some(&2)));
}
/// A document whose text box `ids[k]` holds `fan` shapes showing `ids[k + 1]` (the last one shows
/// itself, `fan` times): crafted, since the app never builds these.
fn box_fan_out(levels: usize, fan: usize) -> Document {
let (w, h) = (40.0, 20.0);
let mut d = Document::from_text("Body");
let ids: Vec<u32> = (0..levels)
.map(|k| {
d.add_part(
wordcraft_doc::PartKind::TextBox,
vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Level {k}"), Default::default()))],
)
})
.collect();
let shape = |story| InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::TextBox,
w,
h,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
for (k, id) in ids.iter().enumerate() {
let next = *ids.get(k + 1).unwrap_or(id);
for _ in 0..fan {
d.insert_object(&Pos { story: StoryRef::Part(*id), path: Path::top(0), off: 0 }, shape(next), &Default::default()).unwrap();
}
}
d.insert_object(&Pos::body(0, 0), shape(ids[0]), &Default::default()).unwrap();
d
}
#[test]
fn self_showing_and_fanned_out_text_boxes_stay_bounded() {
// A box whose 30 shapes all show itself: laid out once.
assert_eq!(lay(&box_fan_out(1, 30)).text_boxes, 1);
// Chains of boxes each showing the next 30 times (30^4 expansions unbounded).
let d = box_fan_out(5, 30);
let l = lay(&d);
assert!(l.text_boxes <= 1 + wordcraft_doc::BoxBudget::MAX_NESTED, "{} boxes laid out", l.text_boxes);
// Footnote numbering's walk is bounded the same way.
let mut visits = 0usize;
d.objects_in_reading_order(&mut |_| visits += 1);
assert!(visits < 5_000, "{visits} visits");
}
#[test]
fn many_text_boxes_and_a_header_box_all_show() {
// Outermost boxes aren't budgeted: a long document's boxes, and its header's on every page,
// all get their text.
let mut d = Document::from_text("Paragraph with a box.");
for _ in 1..400 {
let end = d.end_of(StoryRef::Body);
d.split_paragraph(&end).unwrap();
}
let ids: Vec<u32> = (0..400).map(|k| text_box_at(&mut d, &Pos::body(k, 0), &format!("Box {k}"), 100.0, 30.0, Default::default())).collect();
let hid =
d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Head", Default::default()))]);
d.last_section.headers.default = Some(hid);
let e = d.end_of(StoryRef::Part(hid));
let in_header = text_box_at(&mut d, &e, "Header box", 100.0, 30.0, Default::default());
let l = lay(&d);
assert!(l.pages.len() >= 10, "{} pages", l.pages.len());
for id in ids {
assert!(l.caret(&d.start_of(StoryRef::Part(id))).is_some(), "box {id} has no text");
}
for (i, p) in l.pages.iter().enumerate() {
let shown = p.header.iter().any(|it| matches!(it, Placed::Lines { story: StoryRef::Part(s), .. } if *s == in_header));
assert!(shown, "page {i}: the header box has no text");
}
}
#[test]
fn a_text_box_inside_a_text_box_still_shows_its_text() {
let mut d = Document::from_text("Body");
let outer = text_box(&mut d, 0, "Outer ", 300.0, 200.0, Default::default());
let e = d.end_of(StoryRef::Part(outer));
let inner = text_box_at(&mut d, &e, "Inner", 120.0, 40.0, Default::default());
let l = lay(&d);
assert_eq!(l.text_boxes, 2);
assert!(l.caret(&d.start_of(StoryRef::Part(inner))).is_some(), "the inner box's text is laid out");
}
fn picture(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
}
}
#[test]
fn line_spacing_does_not_scale_pictures() {
use wordcraft_doc::props::LineSpacing;
let mut d = Document::from_text("");
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.props.line_spacing = Some(LineSpacing::Multiple(1.15));
p.insert_object(0, picture(144.0, 144.0, Default::default()), &Default::default()).unwrap();
}
let l = lay(&d);
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
let line = &para.lines[0];
let desc = para.styles[0].descent;
// Word: the picture plus the font's descent, not (picture + descent) x 1.15.
assert!((line.height - (144.0 + desc)).abs() < 0.01, "line {} for a 144pt picture (descent {desc})", line.height);
assert!((line.baseline - line.top - 144.0).abs() < 0.01, "the picture stands on the baseline");
}
#[test]
fn multiple_line_spacing_adds_its_leading_below_the_text() {
use wordcraft_doc::props::LineSpacing;
// (spacing, expected baseline below the line top as a share of the ascent)
for (spacing, share) in [(LineSpacing::Multiple(1.15), 1.0), (LineSpacing::Multiple(2.0), 1.0), (LineSpacing::Multiple(0.8), 0.8)] {
let mut d = Document::from_text(&"Several words wrap onto more lines. ".repeat(30));
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.props.line_spacing = Some(spacing);
}
let l = lay(&d);
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
let asc = para.styles[0].ascent;
assert!(para.lines.len() > 1);
for line in &para.lines {
// Word's 1.15-spaced Times New Roman 12pt sits one ascent below the line top; the
// 0.15 extra goes below the descent (docxide-pdf's hyphenation fixtures).
assert!(
(line.baseline - line.top - asc * share).abs() < 0.01,
"{spacing:?}: baseline {} below the top, ascent {asc}",
line.baseline - line.top
);
}
}
// Exact spacing still keeps the descent below the baseline.
let mut d = Document::from_text("Exact");
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.props.line_spacing = Some(LineSpacing::Exactly(30.0));
}
let l = lay(&d);
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
let line = &para.lines[0];
assert!((line.top + line.height - line.baseline - para.styles[0].descent).abs() < 0.01);
}
#[test]
fn body_starts_right_below_a_tall_header() {
let mut d = Document::from_text("Body");
let mut hp = wordcraft_doc::Paragraph::with_text("", Default::default());
hp.insert_object(0, picture(100.0, 80.0, Default::default()), &Default::default()).unwrap();
hp.props.space_after = Some(0.0);
let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]);
d.last_section.headers.default = Some(id);
let l = lay(&d);
let Some(Placed::Lines { para, y, .. }) = l.pages[0].header.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
let header_bottom = y + para.height;
assert!(header_bottom > d.last_section.margin_top, "the header reaches past the top margin");
// No gap: the body's first paragraph starts where the header ends (Word).
let body = l.pages[0].items.iter().find_map(|i| if let Placed::Lines { y, story: StoryRef::Body, .. } = i { Some(*y) } else { None }).unwrap();
assert!((body - header_bottom).abs() < 0.01, "body at {body}, header ends at {header_bottom}");
}
#[test]
fn inline_picture_keeps_room_for_its_effects() {
let mut d = Document::from_text("");
let shadow = wordcraft_doc::para::Float { effect: [6.0, 12.0, 18.0, 27.0], ..Default::default() };
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.insert_object(0, picture(100.0, 50.0, shadow.clone()), &Default::default()).unwrap();
}
let l = lay(&d);
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
let c = para.clusters.iter().find(|c| matches!(c.kind, para::ClKind::Object(_))).unwrap();
assert_eq!((c.adv, c.obj_h), (124.0, 89.0), "picture plus effect extents");
// Drawn inside that room.
let obj = picture(100.0, 50.0, shadow);
let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, c.obj_h);
assert_eq!((r.x, r.y, r.w, r.h), (16.0, 123.0, 100.0, 50.0));
}
/// Tops of the body text lines drawn on page one, in order.
fn line_tops(l: &DocLayout) -> Vec<f32> {
l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, story: StoryRef::Body, .. } = i { Some(*y) } else { None }).collect()
}
#[test]
fn at_least_rows_add_cell_margins_and_border_bands() {
use wordcraft_doc::props::{Border, BorderStyle, Borders, HeightRule};
let mut d = Document::from_text("");
let line = Some(Border { style: BorderStyle::Single, width: 0.5, color: None, space: 0.0 });
let mut t = Table::new(2, 1, 200.0);
t.props.borders = Some(Borders { top: line, left: line, bottom: line, right: line, between: line, inside_v: line });
t.props.cell_margins = Some([5.0, 5.4, 5.0, 5.4]);
for (r, text) in t.rows.iter_mut().zip(["one", "two"]) {
r.props.height = Some(30.0);
r.props.height_rule = HeightRule::AtLeast;
r.cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
}
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t))];
let tops = line_tops(&lay(&d));
// Word: a 30pt at-least row with 5pt margins and 0.5pt borders steps 40.5pt, and the text
// starts below the top border and the top margin.
assert!((tops[1] - tops[0] - 40.5).abs() < 0.01, "row pitch {}", tops[1] - tops[0]);
assert!((tops[0] - (72.0 + 0.5 + 5.0)).abs() < 0.01, "first cell text at {}", tops[0]);
}
/// `cell_margins` is what the layout uses: a cell's own margins, else the table's, else Word's;
/// each cell's text starts its left margin inside the cell.
#[test]
fn cell_margins_are_where_cell_text_starts() {
let mut d = Document::from_text("");
let mut t = Table::new(1, 2, 300.0);
t.props.cell_margins = Some([0.0, 9.0, 0.0, 3.0]);
t.rows[0].cells[1].props.margins = Some([0.0, 20.0, 0.0, 4.0]);
for (c, text) in t.rows[0].cells.iter_mut().zip(["one", "two"]) {
c.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
}
assert_eq!(cell_margins(&d, &t, 0, 0), [0.0, 9.0, 0.0, 3.0]);
assert_eq!(cell_margins(&d, &t, 0, 1), [0.0, 20.0, 0.0, 4.0]);
assert_eq!(cell_margins(&d, &t, 5, 9), [0.0, 9.0, 0.0, 3.0], "no such cell: the table's");
assert_eq!(cell_margins(&d, &Table::new(1, 1, 100.0), 0, 0), [0.0, 5.4, 0.0, 5.4], "Word's");
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t.clone()))];
let l = lay(&d);
let items = &l.pages[0].items;
for c in 0..2 {
let boxed = items.iter().find_map(|it| match it {
Placed::Cell { rect, cell, .. } if *cell == c => Some(*rect),
_ => None,
});
let text_x = items.iter().find_map(|it| match it {
Placed::Lines { path, x, .. } if path.0.get(2) == Some(&(c as u32)) => Some(*x),
_ => None,
});
let (boxed, text_x) = (boxed.unwrap(), text_x.unwrap());
assert!((text_x - boxed.x - cell_margins(&d, &t, 0, c)[1]).abs() < 0.01, "cell {c}: text at {text_x}, cell at {}", boxed.x);
}
}
/// Line count and, per line, how far the text (trailing spaces excluded) reaches.
fn justified_lines(text: &str, compat_mode: u32, align: Align) -> (usize, Vec<f32>) {
let mut d = Document::from_text(text);
d.settings.compat_mode = compat_mode;
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.align = Some(align)).unwrap();
let l = lay(&d);
let mut ends = Vec::new();
for p in &l.pages {
for it in &p.items {
if let Placed::Lines { para, l0, l1, x, .. } = it {
for li in *l0..*l1 {
let line = &para.lines[li];
let content = text[line.start..line.stop].trim_end().len();
ends.push(para.x_of(li, line.start + content).unwrap() + x);
}
}
}
}
(lines_of(&l), ends)
}
#[test]
fn justified_lines_shrink_spaces_in_word_2013_mode() {
let text = "We tie the spend to a pipe and to the books, so it is in an ad hoc view of all of it. ".repeat(40);
let (modern, ends) = justified_lines(&text, 15, Align::Justify);
let (legacy, legacy_ends) = justified_lines(&text, 14, Align::Justify);
assert!(modern < legacy, "mode 15 fits more per line: {modern} vs {legacy} lines");
// Shrinking only ever pulls a line back inside the margin.
for e in ends.iter().chain(&legacy_ends) {
assert!(*e <= 540.0 + 0.05, "line ends past the right margin: {e}");
}
// Justified lines still reach the margin; the last line is the only short one.
for e in &ends[..ends.len() - 1] {
assert!((*e - 540.0).abs() < 0.05, "justified line ends at {e}");
}
}
#[test]
fn space_shrinking_is_only_for_justified_text() {
let text = "We tie the spend to a pipe and to the books, so it is in an ad hoc view of all of it. ".repeat(40);
for align in [Align::Left, Align::Center, Align::Right] {
assert_eq!(justified_lines(&text, 15, align).0, justified_lines(&text, 14, align).0, "{align:?}");
}
}
#[test]
fn lines_with_tabs_never_shrink() {
let text = "Item\tWe tie the spend to a pipe and to the books, so it is in an ad hoc view of all of it. ".repeat(30);
let (modern, _) = justified_lines(&text, 15, Align::Justify);
let (legacy, _) = justified_lines(&text, 14, Align::Justify);
assert!(modern <= legacy);
let (_, ends) = justified_lines(&text, 15, Align::Justify);
for e in &ends {
assert!(*e <= 540.0 + 0.05, "{e}");
}
}
fn rect_shape(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
}
}
fn shapes(items: &[Placed]) -> Vec<Rect> {
items.iter().filter_map(|i| if let Placed::Shape { rect, .. } = i { Some(*rect) } else { None }).collect()
}
/// Top of the first body line drawn on a page.
fn first_line_top(items: &[Placed]) -> f32 {
items.iter().find_map(|i| if let Placed::Lines { y, story: StoryRef::Body, .. } = i { Some(*y) } else { None }).unwrap()
}
#[test]
fn floats_align_within_their_reference_area() {
use wordcraft_doc::para::{Anchor, Float, FloatAlign, Wrap};
let mut d = Document::from_text("Some text beside the pictures.");
let centred = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Margin, h_align: Some(FloatAlign::Center), x: 999.0, ..Default::default() };
let right = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, h_align: Some(FloatAlign::End), ..Default::default() };
let in_left_margin = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::LeftMargin, x: 10.0, ..Default::default() };
let bottom = Float { wrap: Wrap::InFrontOfText, v_rel: Anchor::BottomMargin, v_align: Some(FloatAlign::Start), ..Default::default() };
for f in [centred, right, in_left_margin, bottom] {
d.insert_object(&Pos::body(0, 0), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
}
let l = lay(&d);
// Objects are inserted at the start, so they come out in reverse order.
let r = shapes(&l.pages[0].items);
assert_eq!(r.len(), 4, "{r:?}");
assert!((r[3].x - (72.0 + (468.0 - 100.0) / 2.0)).abs() < 0.01, "centred in the margins: {:?}", r[3]);
assert!((r[2].right() - 612.0).abs() < 0.01, "right edge of the page: {:?}", r[2]);
assert!((r[1].x - 10.0).abs() < 0.01, "measured from the page's left edge: {:?}", r[1]);
assert!((r[0].y - (792.0 - 72.0)).abs() < 0.01, "top of the bottom margin: {:?}", r[0]);
}
#[test]
fn top_and_bottom_float_pushes_its_own_first_line_down() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let mut d = Document::from_text(&"Text that starts below the picture. ".repeat(10));
let f = Float { wrap: Wrap::TopAndBottom, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, dist_bottom: 6.0, ..Default::default() };
d.insert_object(&Pos::body(0, 0), rect_shape(144.0, 100.0, f), &Default::default()).unwrap();
let l = lay(&d);
let r = shapes(&l.pages[0].items)[0];
let top = first_line_top(&l.pages[0].items);
assert!(top >= r.bottom() + 6.0 - 0.01, "first line {top} drawn under the picture ending at {}", r.bottom());
// The caret agrees with what is drawn.
let c = l.caret(&Pos::body(0, 4)).unwrap();
assert!((c.top - top).abs() < 2.0, "{c:?} vs {top}");
}
#[test]
fn float_follows_its_paragraph_to_the_next_page() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let mut d = Document::from_text(&format!("First page.\n{}", "Text beside the picture on page two. ".repeat(20)));
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, dist: 9.0, ..Default::default() };
d.insert_object(&Pos::body(1, 0), rect_shape(144.0, 100.0, f), &Default::default()).unwrap();
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(1).map(std::sync::Arc::make_mut) {
p.props.page_break_before = Some(true);
}
let l = lay(&d);
assert_eq!(l.pages.len(), 2);
assert!(shapes(&l.pages[0].items).is_empty());
let r = shapes(&l.pages[1].items)[0];
assert!((r.y - 72.0).abs() < 0.5, "at the top of page two: {r:?}");
let Placed::Lines { para, .. } = l.pages[1].items.iter().find(|i| matches!(i, Placed::Lines { .. })).unwrap() else { panic!() };
assert!(para.lines[0].left >= 144.0, "text wraps beside it: {}", para.lines[0].left);
}
#[test]
fn floats_in_headers_and_table_cells_are_drawn() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let mut d = Document::from_text("Body text.");
let logo = Float { wrap: Wrap::BehindText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 400.0, y: 20.0, ..Default::default() };
let mut hp = wordcraft_doc::Paragraph::with_text("Header", Default::default());
hp.insert_object(0, rect_shape(120.0, 60.0, logo), &Default::default()).unwrap();
let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]);
d.last_section.headers.default = Some(id);
let mut t = Table::new(1, 2, 468.0);
let mut cp = wordcraft_doc::Paragraph::with_text("cell", Default::default());
let in_cell = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 5.0, ..Default::default() };
cp.insert_object(0, rect_shape(40.0, 20.0, in_cell), &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cp)];
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let h = shapes(&l.pages[0].header);
assert_eq!(h.len(), 1, "header float drawn");
assert!((h[0].x - 400.0).abs() < 0.01 && (h[0].y - 20.0).abs() < 0.01, "page coordinates: {:?}", h[0]);
assert!(matches!(l.pages[0].header.first(), Some(Placed::Shape { .. })), "behind the header text");
let c = shapes(&l.pages[0].items);
assert_eq!(c.len(), 1, "cell float drawn");
assert!(c[0].x > 72.0 && c[0].x < 72.0 + 234.0, "inside the first cell: {:?}", c[0]);
}
#[test]
fn wrap_area_uses_each_distance() {
use wordcraft_doc::para::{Float, Wrap};
let f = Float { wrap: Wrap::Square, dist: 9.0, dist_top: 0.0, dist_bottom: 4.0, ..Default::default() };
let (r, tb, ..) = wrap_area(Rect::new(10.0, 20.0, 100.0, 50.0), &f, None).unwrap();
assert_eq!((r.x, r.y, r.w, r.h, tb), (1.0, 20.0, 118.0, 54.0, false));
let hostile = Float { wrap: Wrap::TopAndBottom, dist: f32::NAN, dist_top: -5.0, dist_bottom: f32::INFINITY, ..Default::default() };
let (r, tb, ..) = wrap_area(Rect::new(0.0, 0.0, 10.0, 10.0), &hostile, None).unwrap();
assert!(r.w.is_finite() && r.h.is_finite() && tb, "{r:?}");
assert!(wrap_area(Rect::new(0.0, 0.0, 10.0, 10.0), &Float { wrap: Wrap::BehindText, ..Default::default() }, None).is_none());
}
#[test]
fn float_moved_to_a_new_page_sits_at_its_paragraph_top() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let mut d = Document::from_text(&format!("First page.\n{}", "Text beside the picture on page two. ".repeat(20)));
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, dist: 9.0, ..Default::default() };
d.insert_object(&Pos::body(1, 0), rect_shape(144.0, 100.0, f), &Default::default()).unwrap();
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(1).map(std::sync::Arc::make_mut) {
p.props.page_break_before = Some(true);
p.props.space_before = Some(24.0);
}
let l = lay(&d);
let r = shapes(&l.pages[1].items)[0];
let top = first_line_top(&l.pages[1].items);
// The picture sits at the paragraph's start, above its space before, as in Word (`plan/word`
// R22); the text starts below the space before.
let body_top = d.last_section.margin_top;
assert!((r.y - body_top).abs() < 0.5 && top >= body_top, "picture at {} but its paragraph starts at {body_top}", r.y);
}
#[test]
fn page_relative_header_float_wraps_where_it_is_drawn() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let body_top = |wrap| {
let mut d = Document::from_text("Body text.");
let logo = Float { wrap, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 0.0, ..Default::default() };
let mut hp = wordcraft_doc::Paragraph::with_text("Header", Default::default());
hp.insert_object(0, rect_shape(400.0, 20.0, logo), &Default::default()).unwrap();
let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]);
d.last_section.headers.default = Some(id);
first_line_top(&lay(&d).pages[0].items)
};
// The logo sits at the top edge of the page, above the header text, so wrapping text around
// it must not make the header taller (and push the body down) compared with no wrapping.
let (wrapped, in_front) = (body_top(Wrap::TopAndBottom), body_top(Wrap::InFrontOfText));
assert!((wrapped - in_front).abs() < 0.5, "body starts at {wrapped} with wrapping, {in_front} without");
}
/// Issue #149: a tall text box floating behind the text in the header (placed relative to the
/// page, no wrapping) neither makes the header taller nor pushes the body down: the body starts
/// at the top margin and paginates exactly as without the box.
#[test]
fn floating_header_text_box_leaves_the_body_at_the_top_margin() {
use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap};
let body: Vec<String> = (1..=40).map(|i| format!("Body paragraph {i}: synthetic public test content.")).collect();
let make = |float: Option<Float>| {
let mut d = Document::from_text(&body.join("\n"));
// The header starts 18pt from the top, so its two lines end well above the 72pt top
// margin whatever font the default text gets (the line height differs between the
// installed fonts and the bundled fallback: a header at 36pt overflows the margin there).
d.last_section.header = 18.0;
// A label, then the paragraph the box is anchored to (empty in the control).
let tight = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
let mut label = wordcraft_doc::Paragraph::with_text("Header label", Default::default());
label.props = tight.clone();
let mut anchor = wordcraft_doc::Paragraph { props: tight, ..Default::default() };
if let Some(float) = float {
let text = wordcraft_doc::Paragraph::with_text("WATERMARK", Default::default());
let story = d.add_part(wordcraft_doc::PartKind::TextBox, vec![wordcraft_doc::para_block(text)]);
let tb = InlineObject::Shape {
kind: ShapeKind::TextBox,
w: 405.0,
h: 491.4,
fill: None,
stroke: None,
stroke_width: 0.75,
float,
story: Some(story),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
anchor.insert_object(0, tb, &Default::default()).unwrap();
}
let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(label), wordcraft_doc::para_block(anchor)]);
d.last_section.headers.default = Some(id);
let l = lay(&d);
(l.pages.len(), first_line_top(&l.pages[0].items))
};
let (control_pages, control_top) = make(None);
let behind = Float { wrap: Wrap::BehindText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 0.0, y: 80.0, ..Default::default() };
let (pages, top) = make(Some(behind));
assert!(control_pages > 1, "the control needs more than one page: {control_pages}");
assert!((control_top - 72.0).abs() < 0.5, "control body starts at the top margin: {control_top}");
assert!((top - control_top).abs() < 0.5, "body starts at {top} with the floating box, {control_top} without");
assert_eq!(pages, control_pages);
// The same box inline does take room in the header, which is what the reader used to make of it.
let (inline_pages, inline_top) = make(Some(Float::default()));
assert!(inline_top > 400.0 && inline_pages > control_pages, "inline box: body at {inline_top}, {inline_pages} pages");
}
/// List labels drawn, in page order (each paragraph's first line).
fn labels(l: &DocLayout) -> Vec<String> {
l.pages
.iter()
.flat_map(|p| &p.items)
.filter_map(|i| if let Placed::Lines { para, l0: 0, .. } = i { para.label.as_ref().map(|lb| lb.text.clone()) } else { None })
.collect()
}
fn numbered(d: &mut Document, texts: &[&str], props: ParaProps) -> Vec<wordcraft_doc::Paragraph> {
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
texts
.iter()
.map(|t| {
let mut p = wordcraft_doc::Paragraph::with_text(t, Default::default());
p.props = ParaProps { numbering: Some(wordcraft_doc::props::NumRef { num, level: 0 }), ..props.clone() };
p
})
.collect()
}
#[test]
fn list_items_are_counted_once_when_laid_out_again() {
// Contextual spacing drops the space before, so each item's top differs from the first guess.
let mut d = Document::new();
let props = ParaProps { space_before: Some(6.0), contextual_spacing: Some(true), ..Default::default() };
let mut items = numbered(&mut d, &["one", "two", "three"], props);
// A page break before moves the last item after its first layout.
items[2].props.page_break_before = Some(true);
d.body = items.into_iter().map(wordcraft_doc::para_block).collect();
let l = lay(&d);
assert_eq!(l.pages.len(), 2);
assert_eq!(labels(&l), ["1.", "2.", "3."]);
}
#[test]
fn list_items_beside_a_float_in_a_cell_are_counted_once() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let mut d = Document::from_text("Body.");
let mut items = numbered(&mut d, &["first item", "second item", "third item"], ParaProps::default());
// The float makes the cell's paragraphs wrap, so they are laid out a second time.
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, ..Default::default() };
items[0].insert_object(0, rect_shape(40.0, 60.0, f), &Default::default()).unwrap();
let mut t = Table::new(1, 1, 468.0);
t.rows[0].cells[0].blocks = items.into_iter().map(wordcraft_doc::para_block).collect();
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
assert_eq!(labels(&lay(&d)), ["1.", "2.", "3."]);
}
#[test]
fn compatibility_mode_decides_where_a_table_s_edge_sits() {
use wordcraft_doc::props::{Border, BorderStyle, Borders};
let cell_text_x = |mode: u32| {
let mut d = Document::from_text("");
d.settings.compat_mode = mode;
let line = Some(Border { style: BorderStyle::Single, width: 0.5, color: None, space: 0.0 });
let mut t = Table::new(1, 1, 200.0);
t.props.borders = Some(Borders { top: line, left: line, bottom: line, right: line, between: line, inside_v: line });
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("cell", Default::default()))];
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t))];
let l = lay(&d);
l.pages[0].items.iter().find_map(|i| if let Placed::Lines { x, .. } = i { Some(*x) } else { None }).unwrap()
};
// Word 2013+: the border at the margin (moved in by half its width), the text a cell margin
// inside it. Earlier modes: the text at the margin, the border a cell margin outside it.
assert!((cell_text_x(15) - (72.0 + 0.25 + 5.4)).abs() < 0.01, "{}", cell_text_x(15));
assert!((cell_text_x(12) - 72.0).abs() < 0.01, "{}", cell_text_x(12));
}
#[test]
fn floating_tables_take_no_room_and_text_wraps_beside_them() {
use wordcraft_doc::para::Anchor;
use wordcraft_doc::props::TableFloat;
let mut d = Document::from_text("Body text beside the narrow table.");
let cell = |t: &mut Table, text: &str| {
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))]
};
let mut wide = Table::new(1, 1, 468.0);
cell(&mut wide, "Wide");
wide.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, overlap: false, ..Default::default() });
let mut narrow = Table::new(1, 1, 200.0);
cell(&mut narrow, "Narrow");
narrow.props.float =
Some(TableFloat { h_rel: Anchor::Column, v_rel: Anchor::Paragraph, dist: [9.0, 0.0, 9.0, 0.0], overlap: false, ..Default::default() });
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(wide)).unwrap();
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(narrow)).unwrap();
let l = lay(&d);
let lines: Vec<(f32, f32, f32)> = l.pages[0]
.items
.iter()
.filter_map(|i| if let Placed::Lines { x, y, para, .. } = i { Some((*x, *y, para.lines[0].left)) } else { None })
.collect();
let [(wx, wy, _), (nx, ny, _), (bx, by, bleft)] = lines.as_slice() else { panic!("{lines:?}") };
// The wide table stands where the text is; the narrow one may not overlap it, so it goes
// right below, keeping its 9pt from the text's left edge.
assert!(*wy >= 72.0 && *wy < 80.0, "wide at {wy}");
assert!(*ny > *wy + 10.0, "narrow at {ny}, below the wide table at {wy}");
assert!((nx - wx - 9.0).abs() < 0.01, "narrow text at {nx}, wide text at {wx}");
// The paragraph doesn't wait below them: it runs beside the narrow table, level with it.
assert!((by - ny).abs() < 2.0, "body at {by}, narrow table text at {ny}");
assert!(bx + bleft > nx + 200.0, "body text at {}, right of the narrow table", bx + bleft);
}
#[test]
fn a_floating_table_taller_than_a_page_runs_across_pages() {
use wordcraft_doc::para::Anchor;
use wordcraft_doc::props::TableFloat;
let mut d = Document::from_text("After the table.");
let mut t = Table::new(80, 1, 300.0);
for (i, r) in t.rows.iter_mut().enumerate() {
r.cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Row {i}"), Default::default()))];
}
t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: -30.0, overlap: false, ..Default::default() });
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
assert!(l.pages.len() >= 2, "{} page(s)", l.pages.len());
// Every row is drawn on a page, none past a page's bottom margin, and all from the table's
// own left edge.
let mut rows = 0;
for p in &l.pages {
for it in &p.items {
if let Placed::Lines { x, y, story: StoryRef::Body, path, .. } = it
&& path.0.len() > 1
{
rows += 1;
assert!(*y < 792.0 - 72.0, "row drawn at {y}, below the bottom margin");
assert!(*x < 72.0, "row text at {x}, not from the table's edge 30pt left of the margin");
}
}
}
assert_eq!(rows, 80);
}
/// #534: a table placed on the page (`w:tblpPr w:vertAnchor="page"`) whose anchor comes after a
/// page full of text goes to the next page with it, like Word, instead of covering that text.
#[test]
fn a_page_anchored_floating_table_after_a_full_page_moves_to_the_next() {
use wordcraft_doc::para::Anchor;
use wordcraft_doc::props::TableFloat;
let text: Vec<String> = (0..24).map(|i| format!("Paragraph {i} before the table.")).collect();
let mut d = Document::from_text(&(text.join("\n") + "\nAfter the table."));
let mut t = Table::new(15, 1, 300.0);
for (i, r) in t.rows.iter_mut().enumerate() {
r.cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Row {i}"), Default::default()))];
}
t.props.float = Some(TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Page, x: 70.0, y: 71.0, ..Default::default() });
d.insert_block(StoryRef::Body, &Path::top(24), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let page_of = |want: &dyn Fn(&Path) -> bool| -> Vec<usize> {
l.pages
.iter()
.enumerate()
.flat_map(|(i, p)| p.items.iter().map(move |it| (i, it)))
.filter_map(|(i, it)| if let Placed::Lines { story: StoryRef::Body, path, .. } = it { want(path).then_some(i) } else { None })
.collect()
};
// The text before the table fills page 1; the table and the text after it are on page 2.
assert_eq!(page_of(&|p| p.0 == [23]), vec![0]);
let rows = page_of(&|p| p.0.first() == Some(&24) && p.0.len() > 1);
assert!(rows.len() == 15 && rows.iter().all(|pg| *pg == 1), "table rows on pages {rows:?}");
assert_eq!(page_of(&|p| p.0 == [25]), vec![1]);
}
/// #534: a paragraph that starts with a page break right where the page is full keeps the break
/// on that page: no blank page between the two tables around it.
#[test]
fn a_page_break_at_the_bottom_of_a_full_page_leaves_no_blank_page() {
use wordcraft_doc::props::LineSpacing;
let props = ParaProps { line_spacing: Some(LineSpacing::Exactly(24.0)), space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
let para = |text: &str| {
let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default());
p.props = props.clone();
wordcraft_doc::para_block(p)
};
let mut d = Document::from_text("");
// 27 lines of 24pt fill the 648pt of text on a Letter page.
d.body = (0..27).map(|i| para(&format!("Line {i}"))).collect();
d.body.push(para("\u{000C}Next page"));
let l = lay(&d);
assert_eq!(l.pages.len(), 2, "pages: {}", l.pages.len());
let last_on_2 = l.pages[1].items.iter().any(|it| matches!(it, Placed::Lines { path, .. } if path.0 == [27]));
assert!(last_on_2, "the text after the break is on page 2");
}
/// #534: indents in characters (`w:firstLineChars`) are measured in the paragraph mark's font
/// size and win over the indent in points.
#[test]
fn character_indents_use_the_paragraph_mark_size() {
let mut p = wordcraft_doc::Paragraph::with_text(&"字".repeat(80), Default::default());
p.props.indent_first = Some(5.0);
p.props.indent_first_chars = Some(2.0);
p.props.indent_left_chars = Some(1.0);
p.mark.size = Some(20.0);
let mut d = Document::from_text("");
d.body = vec![wordcraft_doc::para_block(p)];
let l = lay(&d);
let para = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some(para.clone()) } else { None }).unwrap();
assert!((para.lines[0].left - 60.0).abs() < 0.01, "first line at {}", para.lines[0].left);
assert!((para.lines[1].left - 20.0).abs() < 0.01, "second line at {}", para.lines[1].left);
}
#[test]
fn no_line_break_right_after_a_slash() {
// Word keeps "and/or" and web addresses whole on a line when they fit.
let text = "word and/or https://example.org/keepers/log/winter ".repeat(40);
let d = Document::from_text(&text);
let l = lay(&d);
let mut lines = 0;
for p in &l.pages {
for it in &p.items {
if let Placed::Lines { para, l0, l1, .. } = it {
for line in &para.lines[*l0..*l1] {
lines += 1;
assert!(!text[..line.start].ends_with('/'), "line starts after a slash: {:?}", &text[line.start..line.stop]);
}
}
}
}
assert!(lines > 10, "{lines} lines");
}
#[test]
fn unequal_formats_lay_out_in_linear_time() {
// Runs whose formats never compare equal (NaN spacing from a hostile file) each get a style;
// finding a run's style must not compare it against every earlier one.
let doc = |n: usize| {
let mut p = wordcraft_doc::Paragraph::with_text("", Default::default());
p.text = "ab ".repeat(n);
p.runs = (0..n)
.map(|i| wordcraft_doc::Run {
len: 3,
props: wordcraft_doc::CharProps { spacing: Some(f32::NAN), bold: Some(i % 2 == 0), ..Default::default() },
})
.collect();
let mut d = Document::from_text("");
d.body = vec![wordcraft_doc::para_block(p)];
d
};
let (t1, tk, ratio) = layout_time_ratio(doc, 5_000, &LayoutOptions::default());
eprintln!("t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}");
assert!(ratio < 24.0, "8x the runs took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
}
#[test]
fn hidden_float_in_a_table_cell_takes_no_room() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
// A square-wrapped shape anchored in hidden text inside a table cell, beside wrapping text.
let mut d = Document::from_text("Body text.");
let mut t = Table::new(1, 2, 468.0);
let mut cp = wordcraft_doc::Paragraph::with_text(&"Cell words wrap around it. ".repeat(8), Default::default());
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, dist: 9.0, ..Default::default() };
let hidden = wordcraft_doc::CharProps { hidden: Some(true), ..Default::default() };
cp.insert_object(0, rect_shape(60.0, 40.0, f), &hidden).unwrap();
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cp)];
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
// (shapes placed, the cell text's widest first-line indent)
let run = |show_hidden: bool| {
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() });
let items = &l.pages[0].items;
let left = items
.iter()
.filter_map(|i| if let Placed::Lines { para, .. } = i { para.lines.first().map(|l| l.left) } else { None })
.fold(0.0f32, f32::max);
(shapes(items).len(), left)
};
let (n, left) = run(true);
assert!(n == 1 && left >= 60.0, "shown: placed and wrapped around ({n}, {left})");
assert_eq!(run(false), (0, 0.0), "hidden: not placed, no wrap area");
}
#[test]
fn objects_left_out_through_the_table_style_formatting() {
// The table style's character formatting sits under the runs' own, as the cell is laid out.
let mut p = wordcraft_doc::Paragraph::with_text("ab", Default::default());
p.insert_object(1, rect_shape(10.0, 10.0, Default::default()), &Default::default()).unwrap();
let d = Document::from_text("");
let hidden = wordcraft_doc::CharProps { hidden: Some(true), ..Default::default() };
assert!(!left_out_objects(&d, &p, None, false, false)(0));
assert!(left_out_objects(&d, &p, Some(&hidden), false, false)(0));
assert!(!left_out_objects(&d, &p, Some(&hidden), true, false)(0), "hidden text shown");
}
#[test]
fn hidden_float_in_a_text_box_is_not_placed() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
// A shape anchored in hidden text inside a text box: no drawing, no hit area.
let mut d = Document::from_text("Body text.");
let id = text_box(&mut d, 0, "Box words.", 200.0, 120.0, Default::default());
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, ..Default::default() };
let hidden = wordcraft_doc::CharProps { hidden: Some(true), ..Default::default() };
let at = Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 };
d.insert_object(&at, rect_shape(30.0, 20.0, f), &hidden).unwrap();
let count = |show_hidden: bool| {
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() });
let items = &l.pages[0].items;
let areas = items.iter().filter(|i| matches!(i, Placed::Object { story: StoryRef::Part(_), .. })).count();
(shapes(items).len(), areas)
};
let (shown, shown_areas) = count(true);
let (hidden_n, hidden_areas) = count(false);
assert_eq!((shown - hidden_n, shown_areas - hidden_areas), (1, 1), "shown {shown}/{shown_areas}, hidden {hidden_n}/{hidden_areas}");
}
#[test]
fn inline_graphic_reserves_its_height() {
let mut d = Document::from_text("Chart\nAfter");
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(Default::default()) };
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let l = lay(&d);
let tops: Vec<f32> = l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, .. } = i { Some(*y) } else { None }).collect();
assert_eq!(tops.len(), 2);
assert!(tops[1] - tops[0] >= 144.0, "the next paragraph starts below the chart: {tops:?}");
}
#[test]
fn graphic_items_draw_inside_the_object() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg, TextAlign};
use wordcraft_doc::props::Rgb;
let items = vec![
GraphicItem::Shape {
rect: [10.0, 5.0, 20.0, 20.0],
kind: wordcraft_doc::para::ShapeKind::Rectangle,
fill: Some(Rgb::BLACK),
stroke: None,
stroke_width: 0.0,
},
GraphicItem::Path {
segs: vec![PathSeg::Move(1.0, 2.0), PathSeg::Line(30.0, 40.0), PathSeg::Close],
fill: None,
stroke: Some(Rgb::BLACK),
stroke_width: 0.0,
},
GraphicItem::Text {
rect: [0.0, 100.0, 216.0, 20.0],
text: "Q1".into(),
size: 10.0,
color: Rgb::BLACK,
bold: false,
align: TextAlign::Center,
font: None,
},
];
let mut d = Document::from_text("Chart");
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let l = lay(&d);
// The chart's draws sit inside its figure.
let draws = flat(display::page_display(&d, &l.pages[0], &Default::default()));
let shape = draws.iter().find_map(|x| if let display::Draw::Shape { rect, .. } = x { Some(*rect) } else { None }).expect("a shape");
let first = draws.iter().find_map(|x| if let display::Draw::Path { segs, .. } = x { segs.first().copied() } else { None }).expect("a path");
let PathSeg::Move(mx, my) = first else { panic!("{first:?}") };
// Path points are page coordinates, moved with the object like the shape's rectangle.
assert_eq!((mx - shape.x, my - shape.y), (1.0 - 10.0, 2.0 - 5.0));
assert!(draws.iter().any(|x| matches!(x, display::Draw::Glyphs { text, .. } if text == "Q1")));
}
#[test]
fn graphic_text_in_a_degenerate_rectangle_is_not_drawn() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, TextAlign};
use wordcraft_doc::props::Rgb;
let text = |rect: [f32; 4], size: f32| GraphicItem::Text {
rect,
text: "Q1".into(),
size,
color: Rgb::BLACK,
bold: false,
align: TextAlign::Center,
font: None,
};
let items = vec![text([f32::NAN, 0.0, 10.0, 10.0], 10.0), text([0.0, 0.0, f32::INFINITY, 10.0], 10.0), text([0.0, 0.0, 10.0, 10.0], f32::NAN)];
let graphic = Graphic { kind: GraphicKind::Diagram, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default()));
assert!(!draws.iter().any(|x| matches!(x, display::Draw::Glyphs { text, .. } if text == "Q1")));
}
/// The draws with figures opened up.
fn flat(draws: Vec<display::Draw>) -> Vec<display::Draw> {
draws.into_iter().flat_map(|x| if let display::Draw::Figure { draws, .. } = x { draws } else { vec![x] }).collect()
}
#[test]
fn inline_chart_is_a_figure_with_its_alt_text() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg};
use wordcraft_doc::props::Rgb;
let items = vec![GraphicItem::Path {
segs: vec![PathSeg::Move(1.0, 1.0), PathSeg::Line(2.0, 2.0)],
fill: None,
stroke: Some(Rgb::BLACK),
stroke_width: 1.0,
}];
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: "Sales by quarter".into(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let draws = display::page_display(&d, &lay(&d).pages[0], &Default::default());
let figure = draws.iter().find_map(|x| if let display::Draw::Figure { alt, draws, .. } = x { Some((alt.clone(), draws.len())) } else { None });
assert_eq!(figure, Some(("Sales by quarter".to_string(), 1)));
}
#[test]
fn graphic_paths_drop_segments_that_cannot_be_drawn() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg};
use wordcraft_doc::props::Rgb;
// The line before the first move, the move with a NaN, the infinite line and the line after the
// close are all dropped.
let segs = vec![
PathSeg::Line(5.0, 5.0),
PathSeg::Move(f32::NAN, 1.0),
PathSeg::Move(1.0, 2.0),
PathSeg::Line(3.0, 4.0),
PathSeg::Line(f32::INFINITY, 0.0),
PathSeg::Close,
PathSeg::Line(9.0, 9.0),
];
let items = vec![GraphicItem::Path { segs, fill: None, stroke: Some(Rgb::BLACK), stroke_width: 1.0 }];
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default()));
let segs: Vec<PathSeg> =
draws.iter().find_map(|x| if let display::Draw::Path { segs, .. } = x { Some(segs.clone()) } else { None }).expect("a path");
assert!(matches!(segs.as_slice(), [PathSeg::Move(x, y), PathSeg::Line(..), PathSeg::Close] if x.is_finite() && y.is_finite()), "{segs:?}");
}
#[test]
fn graphic_items_scale_with_their_frame() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind};
use wordcraft_doc::props::Rgb;
// A chart drawn for 1000 x 500 pt in a 500 x 250 pt frame: its items and strokes scale by half.
let items = vec![GraphicItem::Shape {
rect: [0.0, 0.0, 1000.0, 500.0],
kind: wordcraft_doc::para::ShapeKind::Rectangle,
fill: None,
stroke: Some(Rgb::BLACK),
stroke_width: 4.0,
}];
let graphic = Graphic { kind: GraphicKind::Chart, items, w: 1000.0, h: 500.0, source: None, chart: None, smart_art: None };
let obj = InlineObject::Graphic { w: 500.0, h: 250.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default()));
let (r, stroke) = draws
.iter()
.find_map(|x| if let display::Draw::Shape { rect, stroke_width, .. } = x { Some((*rect, *stroke_width)) } else { None })
.expect("a shape");
assert!((r.w - 500.0).abs() < 1e-3 && (r.h - 250.0).abs() < 1e-3, "{r:?}");
assert!((stroke - 2.0).abs() < 1e-5, "stroke {stroke}");
}
#[test]
fn inline_picture_wider_than_a_text_box_is_fitted_to_it() {
// Text boxes don't clip what runs past their sides, so a 400 x 100 pt picture in a 144 pt box
// is fitted to the box's 129.6 pt inside its margins instead of spilling over the page.
let mut d = Document::from_text("Before");
let mut p = wordcraft_doc::Paragraph::with_text("", Default::default());
p.insert_object(0, picture(400.0, 100.0, Default::default()), &Default::default()).unwrap();
let id = d.add_part(wordcraft_doc::PartKind::TextBox, vec![wordcraft_doc::para_block(p)]);
let shape = InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::TextBox,
w: 144.0,
h: 72.0,
fill: None,
stroke: None,
stroke_width: 0.75,
float: Default::default(),
story: Some(id),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let l = lay(&d);
let draws = flat(display::page_display(&d, &l.pages[0], &Default::default()));
let r = draws
.iter()
.find_map(|x| match x {
display::Draw::Shape { rect, kind: wordcraft_doc::para::ShapeKind::Rectangle, .. } => Some(*rect),
_ => None,
})
.expect("the picture");
assert!((r.w - 129.6).abs() < 0.1 && (r.h - 32.4).abs() < 0.1, "{r:?}");
}
#[test]
fn inline_picture_wider_than_the_text_keeps_its_size() {
// A 1000 x 100 pt picture in 468 pt of text: Word draws it at its size, past the margin, and the
// page edge clips it; the line is as tall as the picture (docxide-pdf's case179).
let mut d = Document::from_text("");
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.insert_object(0, picture(1000.0, 100.0, Default::default()), &Default::default()).unwrap();
}
let l = lay(&d);
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
let line = &para.lines[0];
assert!((line.baseline - line.top - 100.0).abs() < 0.01, "the picture keeps its height: {line:?}");
let draws = flat(display::page_display(&d, &l.pages[0], &Default::default()));
let r = draws.iter().find_map(|x| if let display::Draw::Shape { rect, .. } = x { Some(*rect) } else { None }).expect("the picture");
assert!((r.w - 1000.0).abs() < 0.01 && (r.h - 100.0).abs() < 0.01, "{r:?}");
}
/// The paragraph at `path` on page 0: its first line's x, its y and its line count.
fn para_at(l: &DocLayout, path: &[u32]) -> Vec<(f32, f32, usize)> {
l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { x, y, para, path: p, .. } if p.0 == path => Some((*x + para.lines.first().map_or(0.0, |l| l.left), *y, para.lines.len())),
_ => None,
})
.collect()
}
#[test]
fn a_floating_table_in_a_cell_takes_no_room_and_the_cell_holds_it() {
use wordcraft_doc::para::Anchor;
use wordcraft_doc::props::TableFloat;
let text = |s: &str| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default()));
let mut inner = Table::new(3, 1, 200.0);
for (i, r) in inner.rows.iter_mut().enumerate() {
r.cells[0].blocks = vec![text(&format!("Inner {i}"))];
}
inner.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, dist: [9.0, 0.0, 9.0, 0.0], ..Default::default() });
let mut outer = Table::new(1, 1, 468.0);
outer.rows[0].cells[0].blocks = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(inner)), text("Beside")];
let mut d = Document::from_text("Below");
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(outer)).unwrap();
let l = lay(&d);
let (&[(ix, iy, _)], &[(_, last_y, _)], &[(bx, by, _)], &[(_, below_y, _)]) =
(&para_at(&l, &[0, 0, 0, 0, 0, 0, 0])[..], &para_at(&l, &[0, 0, 0, 0, 2, 0, 0])[..], &para_at(&l, &[0, 0, 0, 1])[..], &para_at(&l, &[1])[..])
else {
panic!("lines missing")
};
// The paragraph after the floating table runs beside it, level with its top.
assert!((by - iy).abs() < 2.0, "beside at {by}, inner table at {iy}");
assert!(bx > ix + 200.0, "beside text at {bx}, inner table text at {ix}");
// The cell grows to hold the whole floating table.
assert!(below_y > last_y + 10.0, "text below the outer table at {below_y}, the inner table's last row at {last_y}");
}
#[test]
fn autofit_sizes_columns_by_their_content() {
let text = |s: &str| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default()));
// No width anywhere: the columns come from their content.
let mut t = Table::new(1, 2, 468.0);
t.rows[0].cells[0].blocks = vec![text("Short")];
t.rows[0].cells[1].blocks = vec![text(&"A long cell that needs most of the width. ".repeat(4))];
t.rows[0].cells.iter_mut().for_each(|c| c.props.width = None);
let mut d = Document::from_text("After");
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let (&[(sx, _, _)], &[(lx, _, _)]) = (&para_at(&l, &[0, 0, 0, 0])[..], &para_at(&l, &[0, 0, 1, 0])[..]) else { panic!("lines missing") };
assert!(lx - sx < 80.0, "the short column is {}pt wide", lx - sx);
// A grid column narrower than its longest word grows; the others give up the difference.
let mut t = Table::new(1, 3, 468.0);
t.grid = vec![400.0, 20.0, 48.0];
t.rows[0].cells[1].blocks = vec![text("Unbreakable")];
let mut d = Document::from_text("After");
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let [(_, _, n)] = para_at(&l, &[0, 0, 1, 0])[..] else { panic!("line missing") };
assert_eq!(n, 1, "the word is broken over {n} lines");
}
#[test]
fn autofit_handles_spanning_cells_long_words_and_empty_tables() {
let text = |s: &str| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default()));
let table_x = |t: Table, path: &[u32]| {
let mut d = Document::from_text("After");
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
para_at(&lay(&d), path)
};
let automatic = |rows: usize, cols: usize| {
let mut t = Table::new(rows, cols, 468.0);
t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()).for_each(|c| c.props.width = None);
t
};
// A title cell spanning both columns needs its width shared between them: it stays on one line.
let mut t = automatic(2, 2);
t.rows[0].cells.truncate(1);
t.rows[0].cells[0].props.span = 2;
t.rows[0].cells[0].blocks = vec![text("A title that spans both columns")];
t.rows[1].cells[0].blocks = vec![text("A")];
t.rows[1].cells[1].blocks = vec![text("B")];
let [(_, _, n)] = table_x(t, &[0, 0, 0, 0])[..] else { panic!("title missing") };
assert_eq!(n, 1, "the title wraps over {n} lines");
// An unbreakable string longer than the page doesn't widen the table past it.
let mut t = automatic(1, 2);
t.rows[0].cells[0].blocks = vec![text(&"x".repeat(300))];
t.rows[0].cells[1].blocks = vec![text("Second")];
let [(x, _, _)] = table_x(t, &[0, 0, 1, 0])[..] else { panic!("second cell missing") };
assert!(x < 72.0 + 468.0, "the second column starts at {x}, past the margin");
// A table of empty cells keeps its grid.
let [(x, _, _)] = table_x(automatic(1, 2), &[0, 0, 1, 0])[..] else { panic!("second cell missing") };
assert!(x > 72.0 + 200.0, "the second empty column starts at {x}");
// Cells asking for a share of the table's width keep their grid too.
let mut t = automatic(1, 2);
for (c, s) in t.rows[0].cells.iter_mut().zip(["A", "B"]) {
c.props.width_pct = Some(50.0);
c.blocks = vec![text(s)];
}
let [(x, _, _)] = table_x(t, &[0, 0, 1, 0])[..] else { panic!("second cell missing") };
assert!(x > 72.0 + 200.0, "the second 50% column starts at {x}");
}
#[test]
fn a_floating_table_in_the_footer_leaves_the_footer_where_it_is() {
use wordcraft_doc::para::Anchor;
use wordcraft_doc::props::TableFloat;
let footer_y = |float: bool| {
let mut d = Document::from_text("Body");
let mut t = Table::new(1, 1, 100.0);
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Float", Default::default()))];
if float {
t.props.float = Some(TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Page, y: 720.0, ..Default::default() });
}
let blocks = vec![
wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Footer", Default::default())),
std::sync::Arc::new(wordcraft_doc::Block::Table(t)),
];
let id = d.add_part(wordcraft_doc::PartKind::Footer, blocks);
d.last_section.footers.default = Some(id);
let l = lay(&d);
l.pages[0].footer.iter().find_map(|it| if let Placed::Lines { y, path, .. } = it { (path.0 == [0]).then_some(*y) } else { None }).unwrap()
};
// A floating table in the footer doesn't push the footer's text up.
assert!(footer_y(true) >= footer_y(false) - 0.01, "footer text at {} with the float, {} without", footer_y(true), footer_y(false));
}
#[test]
fn a_split_row_moves_shading_below_the_cut_with_it() {
use crate::table::{RowLayout, split_row};
let line_row = |y: f32| {
let d = Document::from_text("Line");
let l = lay(&d);
let Some(Placed::Lines { story, path, para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })).cloned() else {
panic!("no line")
};
Placed::Lines { story, path, para, l0: 0, l1: 1, x: 0.0, y, turn: TextDirection::Horizontal }
};
let fill = |y: f32, h: f32| Placed::Fill { rect: wordcraft_geom::Rect::new(0.0, y, 100.0, h), color: wordcraft_doc::props::Rgb(200, 0, 0) };
// The row's own shading (from its top) and a nested cell's shading starting below the cut.
let row = RowLayout { height: 200.0, items: vec![line_row(0.0), line_row(150.0), fill(0.0, 200.0), fill(150.0, 50.0)] };
let (a, b) = split_row(&row, 100.0).expect("splits");
let fills = |r: &RowLayout| {
r.items.iter().filter_map(|i| if let Placed::Fill { rect, .. } = i { Some((rect.y, rect.h)) } else { None }).collect::<Vec<_>>()
};
assert_eq!(fills(&a), vec![(0.0, 100.0)], "only the row's shading above the cut stays");
let moved = fills(&b);
assert_eq!(moved.first().map(|f| f.0), Some(0.0), "the row's shading starts the continuation: {moved:?}");
let nested = moved.get(1).copied().unwrap_or_default();
assert!(nested.0 > 0.0 && (nested.1 - 50.0).abs() < 0.01, "the nested shading keeps its place and size: {moved:?}");
}
#[test]
fn kinsoku_keeps_a_manual_line_break_before_a_closing_bracket() {
// #269: kinsoku forbids a line starting with 」, but a manual line break (Shift+Enter) right
// before one still ends the line there.
let text = "日本語\n」テスト";
let mut d = Document::from_text("");
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
let l = lay(&d);
let mut lines = Vec::new();
for p in &l.pages {
for it in &p.items {
if let Placed::Lines { para, l0, l1, .. } = it {
lines.extend(para.lines[*l0..*l1].iter().map(|line| (line.start, line.end)));
}
}
}
let bracket = text.find('」').unwrap();
assert_eq!(lines.len(), 2, "{lines:?}");
assert_eq!(lines[0].1, LineEnd::LineBreak, "{lines:?}");
assert_eq!(lines[1].0, bracket, "the second line starts at the bracket: {lines:?}");
}
/// #332: a rotated floating shape keeps text clear of its rotated bounds (it reaches further at
/// the side), its frame stays put (rotation is about the centre) and it is drawn turned; hit
/// testing follows the turned shape, not its frame.
#[test]
fn rotated_float_wraps_around_its_rotated_bounds() {
let text = "Words flow around the turned shape here. ".repeat(30);
let place = |rot: f32| {
let mut d = Document::from_text(&text);
let float = wordcraft_doc::para::Float {
wrap: wordcraft_doc::para::Wrap::Square,
h_rel: wordcraft_doc::para::Anchor::Column,
v_rel: wordcraft_doc::para::Anchor::Paragraph,
x: 0.0,
y: 40.0,
rot,
..Default::default()
};
let shape = InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w: 144.0,
h: 40.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let p = &l.pages[0];
let Some(Placed::Lines { para, .. }) = p.items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
// The widest indent of a line beside the shape.
let left = para.lines.iter().map(|l| l.left).fold(0.0f32, f32::max);
let hit = l.object(&Pos::body(0, 0), 0).unwrap();
let drawn = crate::display::page_display(&d, p, &Default::default()).iter().any(|d| matches!(d, crate::display::Draw::Rotated { .. }));
(left, hit, drawn, l)
};
let (flat, frame, flat_drawn, _) = place(0.0);
let (turned, hit, drawn, l) = place(30.0);
// 144 × 40 turned 30° is 144.7 wide: 0.36 pt further out on each side, the centre kept.
let bounds = hit.bounds();
assert!((bounds.w - (144.0 * 30f32.to_radians().cos() + 40.0 * 0.5)).abs() < 0.01, "{bounds:?}");
assert_eq!(hit.rect, frame.rect);
assert!(turned > flat && turned >= bounds.right() - hit.origin.x, "{flat} → {turned} ({bounds:?})");
assert!(drawn && !flat_drawn);
// Its frame's top-right corner turns away from where it was: no longer on the shape.
let r = hit.rect;
assert!(l.object_at(0, r.right() - 2.0, r.y + 2.0, 0.0).is_none());
assert!(l.object_at(0, r.x + r.w / 2.0, r.y + r.h / 2.0, 0.0).is_some());
}
/// #332: a group turns as a whole: each member's centre is carried round the group's centre and
/// the member drawn with the group's turn on top of its own; hit testing follows the turned
/// group. A shadow that doesn't rotate with its shape (`rotWithShape="0"`, Word's presets) keeps
/// its page direction inside the turn; one that does turns with the shape.
#[test]
fn rotated_group_turns_its_members_and_their_shadows() {
use wordcraft_doc::effects::{Shadow, ShapeEffects};
use wordcraft_doc::para::{Anchor, Float, GroupChild, ShapeKind, Wrap};
let member = |rot_with_shape: bool, rot: f32| InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 60.0,
h: 40.0,
fill: Some(wordcraft_doc::props::Rgb(1, 2, 3)),
stroke: None,
stroke_width: 0.0,
float: Float { rot, ..Default::default() },
story: None,
freeform: None,
effects: ShapeEffects { shadow: Some(Shadow { angle: 0.0, rot_with_shape, ..Default::default() }), ..Default::default() },
extra: Default::default(),
};
let float =
Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 100.0, y: 100.0, rot: 90.0, ..Default::default() };
let group = InlineObject::Group {
w: 120.0,
h: 40.0,
float,
ch_w: 120.0,
ch_h: 40.0,
children: vec![GroupChild { x: 0.0, y: 0.0, obj: member(false, 0.0) }, GroupChild { x: 60.0, y: 0.0, obj: member(true, 10.0) }],
canvas: None,
};
let mut d = Document::from_text("Text under the group.");
d.insert_object(&Pos::body(0, 0), group, &Default::default()).unwrap();
let l = lay(&d);
let p = &l.pages[0];
let hit = l.object(&Pos::body(0, 0), 0).unwrap();
let g = hit.rect;
let (cx, cy) = (g.x + g.w / 2.0, g.y + g.h / 2.0);
assert_eq!(hit.spin.deg, 90.0);
// Turned a quarter clockwise, the left member is now above the centre, the right one below.
let shapes: Vec<(Rect, Spin)> =
p.items.iter().filter_map(|i| if let Placed::Shape { rect, spin, .. } = i { Some((*rect, *spin)) } else { None }).collect();
assert_eq!(shapes.len(), 2, "{shapes:?}");
let centre = |r: Rect| (r.x + r.w / 2.0, r.y + r.h / 2.0);
let near = |a: (f32, f32), b: (f32, f32)| (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01;
assert!(near(centre(shapes[0].0), (cx, cy - 30.0)), "{shapes:?} about ({cx}, {cy})");
assert!(near(centre(shapes[1].0), (cx, cy + 30.0)), "{shapes:?}");
assert_eq!((shapes[0].1.deg, shapes[1].1.deg), (90.0, 100.0));
// Drawn turned: the page-fixed shadow still falls to the right on the page (270° in the
// quarter-turned frame); the other turns with its shape (still 0° in its own frame).
let shadows: Vec<f32> = crate::display::page_display(&d, p, &Default::default())
.iter()
.filter_map(|d| match d {
crate::display::Draw::Rotated { items, .. } => items.iter().find_map(|i| match i {
crate::display::Draw::Shape { effects, .. } => effects.shadow.map(|s| s.angle),
_ => None,
}),
_ => None,
})
.collect();
assert_eq!(shadows.len(), 2, "{shadows:?}");
assert!((shadows[0] - 270.0).abs() < 0.01 && shadows[1].abs() < 0.01, "{shadows:?}");
// Grabbed on the turned group: its frame's left end is empty now, above the centre isn't.
assert!(l.object_at(0, g.x + 5.0, cy, 0.0).is_none());
assert!(l.object_at(0, cx, cy - 50.0, 0.0).is_some());
}
/// The pieces of footnote `id` as (page, first line, end line, top).
fn note_pieces(l: &DocLayout, id: u32) -> Vec<(usize, usize, usize, f32)> {
let mut v = Vec::new();
for (pi, p) in l.pages.iter().enumerate() {
for it in &p.items {
if let Placed::Lines { story: StoryRef::Part(s), l0, l1, y, .. } = it
&& *s == id
{
v.push((pi, *l0, *l1, *y));
}
}
}
v
}
/// Lengths of the horizontal rules on page `pi` below `y`.
fn rules_below(l: &DocLayout, pi: usize, y: f32) -> Vec<f32> {
l.pages[pi]
.items
.iter()
.filter_map(|it| if let Placed::Rule { x0, y0, x1, y1, .. } = it { (*y0 == *y1 && *y0 > y).then_some(x1 - x0) } else { None })
.collect()
}
#[test]
fn long_footnotes_continue_on_the_next_page() {
let mut d = Document::from_text(&"Body text line.\n".repeat(16));
let long = footnote(&mut d, &Pos::body(4, 4), &"A long footnote that runs on and on. ".repeat(100));
let own = footnote(&mut d, &Pos::body(14, 4), "The second page's own note.");
let l = lay(&d);
let refs = l.caret(&Pos::body(4, 4)).unwrap();
assert_eq!(refs.page, 0, "the reference stays where it was");
let pieces = note_pieces(&l, long);
assert!(pieces.len() >= 2, "{pieces:?}");
let (p0, p1) = (&pieces[0], &pieces[1]);
assert_eq!((p0.0, p1.0), (0, 1), "split over pages 1 and 2: {pieces:?}");
assert_eq!(p0.1, 0);
assert_eq!(p0.2, p1.1, "continues at the next line");
// Every line of the note is placed once.
let lines = pieces.iter().map(|p| p.2 - p.1).sum::<usize>();
let para = l.pages[0]
.items
.iter()
.find_map(|it| if let Placed::Lines { story: StoryRef::Part(s), para, .. } = it { (*s == long).then(|| para.lines.len()) } else { None })
.unwrap();
assert_eq!(lines, para);
// Page 1: the note fills the space under the text, which ends above it.
let body_bottom = |pi: usize| {
l.pages[pi]
.items
.iter()
.filter_map(
|it| if let Placed::Lines { story: StoryRef::Body, y, para, l0, l1, .. } = it { item_bottom(*y, para, *l0, *l1) } else { None },
)
.fold(0.0f32, f32::max)
};
assert!(body_bottom(0) <= p0.3, "{} {}", body_bottom(0), p0.3);
assert_eq!(rules_below(&l, 0, body_bottom(0)), vec![144.0], "a normal separator on page 1");
// Page 2: the continuation separator spans the text width, the rest of the long note comes
// first and the page's own note follows it.
assert!(body_bottom(1) <= p1.3, "{} {}", body_bottom(1), p1.3);
assert_eq!(rules_below(&l, 1, body_bottom(1)), vec![468.0], "continuation separator");
let own_ref = l.caret(&Pos::body(14, 4)).unwrap();
let own_note = l.caret(&d.start_of(StoryRef::Part(own))).unwrap();
assert_eq!((own_ref.page, own_note.page), (1, 1));
assert!(own_note.top > p1.3, "own note after the continued one: {own_note:?} {p1:?}");
}
#[test]
fn short_footnotes_stay_whole() {
let mut d = Document::from_text(&"Body text line.\n".repeat(60));
let id = footnote(&mut d, &Pos::body(10, 4), "A short note, two lines long. ".repeat(4).as_str());
let l = lay(&d);
let pieces = note_pieces(&l, id);
assert_eq!(pieces.len(), 1, "{pieces:?}");
assert_eq!(pieces[0].0, l.caret(&Pos::body(10, 4)).unwrap().page);
}
#[test]
fn footnote_longer_than_pages_ends() {
// A note several pages long after a one-line body: it continues on pages of its own.
let mut d = Document::from_text("Body");
let id = footnote(&mut d, &Pos::body(0, 4), &"Words in a very long note. ".repeat(1500));
let l = lay(&d);
let pieces = note_pieces(&l, id);
assert!(pieces.len() >= 3 && l.pages.len() == pieces.len(), "{} pages, {pieces:?}", l.pages.len());
assert!(pieces.windows(2).all(|w| w[0].2 == w[1].1 && w[1].0 == w[0].0 + 1), "{pieces:?}");
}
/// A comment by `author` saying `text` on body paragraph `para`, bytes `a..b`; its id.
fn comment_on(d: &mut Document, para: usize, a: usize, b: usize, author: &str, text: &str, parent: Option<u32>) -> u32 {
let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text(text, CharProps::default()))]);
let id = d.comments.keys().next_back().map_or(0, |k| k + 1);
d.comments.insert(id, wordcraft_doc::Comment { author: author.into(), part, parent, ..Default::default() });
if parent.is_none() {
let p = CharProps::default();
d.insert_object(&Pos::body(para, b), InlineObject::CommentEnd { id }, &p).unwrap();
d.insert_object(&Pos::body(para, a), InlineObject::CommentStart { id }, &p).unwrap();
}
id
}
fn balloon_layout(d: &Document) -> DocLayout {
layout(d, &mut LayoutCache::new(), &LayoutOptions { comment_balloons: true, ..Default::default() })
}
/// The comment text lines placed on page `pi` (story, top-left).
fn comment_lines(l: &DocLayout, pi: usize) -> Vec<(StoryRef, f32, f32)> {
l.pages[pi]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: s @ StoryRef::Part(_), x, y, .. } => Some((*s, *x, *y)),
_ => None,
})
.collect()
}
/// Print Markup (#589): each page grows by the markup area, and the comment, its author and its
/// reply are laid out there beside the line they comment on.
#[test]
fn comment_balloons_widen_pages_beside_their_anchors() {
let mut d = Document::from_text("Intro line.\nThe quarterly figures look strong.\nClosing line.");
let id = comment_on(&mut d, 1, 4, 13, "Ada Reviewer", "Check the totals", None);
comment_on(&mut d, 0, 0, 0, "Ben Editor", "Totals checked", Some(id));
let plain = lay(&d);
let l = balloon_layout(&d);
assert_eq!(l.pages.len(), plain.pages.len());
let (w, pw) = (l.pages[0].w, plain.pages[0].w);
assert_eq!(l.pages[0].markup, balloons::MARKUP_AREA);
assert!((w - pw - balloons::MARKUP_AREA).abs() < 0.01, "{w} vs {pw}");
assert_eq!(plain.pages[0].markup, 0.0);
// Author + text for the comment, then for the reply: four lines, all in the markup area.
let lines = comment_lines(&l, 0);
assert_eq!(lines.len(), 4, "{lines:?}");
assert!(lines.iter().all(|(_, x, _)| *x > pw && *x < w), "{lines:?}");
assert!(lines.windows(2).all(|p| p[0].2 < p[1].2), "stacked top to bottom: {lines:?}");
// The card sits by the commented line.
let anchor = l.caret(&Pos::body(1, 5)).unwrap();
assert!((lines[0].2 - anchor.top).abs() < 30.0, "{lines:?} vs {anchor:?}");
// The body is laid out exactly as without balloons.
assert_eq!(lines_of(&plain), lines_of(&l) - 4);
// The leader starts at the anchor.
assert!(l.pages[0].items.iter().any(|it| matches!(it, Placed::Rule { x0, x1, .. } if (*x0 - anchor.x).abs() < 0.5 && (*x1 - pw).abs() < 0.5)));
}
#[test]
fn no_balloons_without_anchored_comments() {
let d = Document::from_text("No comments here.");
let l = balloon_layout(&d);
assert_eq!(l.pages[0].markup, 0.0);
assert_eq!(l.pages[0].w, lay(&d).pages[0].w);
// A web view never gets a markup area.
let mut d = Document::from_text("Hello there.");
comment_on(&mut d, 0, 0, 5, "A", "x", None);
let opts = LayoutOptions { view: ViewMode::Web, web_width: 500.0, comment_balloons: true, ..Default::default() };
let l = layout(&d, &mut LayoutCache::new(), &opts);
assert_eq!(l.pages[0].markup, 0.0);
}
/// Hostile comments: parent cycles, a missing text part, and far more comments than fit beside
/// one page. Nothing panics or hangs, and the cards stay on the page.
#[test]
fn comment_balloons_survive_hostile_comments() {
let mut d = Document::from_text(&"Commented words here. ".repeat(20));
let mut ids = Vec::new();
for i in 0..120 {
ids.push(comment_on(&mut d, 0, 0, 9, &format!("Author {i}"), &"A long remark that wraps over lines. ".repeat(4), None));
}
// A reply cycle and a comment whose text part is gone.
let a = comment_on(&mut d, 0, 0, 0, "R", "one", Some(ids[0]));
let b = comment_on(&mut d, 0, 0, 0, "R", "two", Some(a));
d.comments.get_mut(&a).unwrap().parent = Some(b);
d.comments.get_mut(&ids[1]).unwrap().parent = Some(ids[1]);
d.comments.get_mut(&ids[2]).unwrap().part = 9_999_999;
let l = balloon_layout(&d);
let page = &l.pages[0];
for (_, _, y) in comment_lines(&l, 0) {
assert!(y.is_finite() && y >= 0.0 && y <= page.h, "{y}");
}
for it in &page.items {
if let Placed::Fill { rect, .. } = it {
assert!(rect.y >= 0.0 && rect.bottom() <= page.h + 0.5, "{rect:?}");
}
}
}
/// The first paragraph's lines on page one: (top, bottom, left, right), relative to its column.
fn line_spans(l: &DocLayout) -> Vec<(f32, f32, f32, f32)> {
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { return Vec::new() };
para.lines.iter().map(|ln| (ln.top, ln.top + ln.height, ln.left, ln.right)).collect()
}
/// A shape floating at the column's top-left with `wrap`, beside a long paragraph.
fn wrapped_shape_doc(kind: wordcraft_doc::para::ShapeKind, w: f32, h: f32, float: wordcraft_doc::para::Float) -> Document {
let mut d = Document::from_text(&"Words flow around the shape here. ".repeat(40));
let float =
wordcraft_doc::para::Float { h_rel: wordcraft_doc::para::Anchor::Column, v_rel: wordcraft_doc::para::Anchor::Paragraph, dist: 9.0, ..float };
let shape = InlineObject::Shape {
kind,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
d
}
#[test]
fn tight_wrap_follows_an_ellipse() {
use wordcraft_doc::para::{Float, ShapeKind, Wrap};
let spans = |wrap| line_spans(&lay(&wrapped_shape_doc(ShapeKind::Ellipse, 160.0, 160.0, Float { wrap, ..Default::default() })));
let (square, tight, through) = (spans(Wrap::Square), spans(Wrap::Tight), spans(Wrap::Through));
// Square: every line beside the shape starts past its frame and distance from text.
assert!(square.iter().filter(|l| l.0 < 160.0).all(|l| l.2 >= 169.0 - 0.01), "{square:?}");
// Tight: no line runs into the ellipse (a 160pt circle at 80, 80) or its distance from text.
// Where a line from `top` to `bottom` meets the circle plus that distance: (left, right) edges.
let contour = |top: f32, bottom: f32| {
let y = if bottom < 80.0 {
80.0 - bottom
} else if top > 80.0 {
top - 80.0
} else {
0.0
};
let half = (80.0f32 * 80.0 - y.min(80.0).powi(2)).sqrt();
(80.0 - half - 9.0, 80.0 + half + 9.0)
};
for &(top, bottom, left, right) in tight.iter().filter(|l| l.0 < 160.0) {
let (a, b) = contour(top, bottom);
assert!(right <= a + 1.5 || left >= b - 1.5, "line {top}..{bottom} at {left}..{right} runs into {a}..{b}: {tight:?}");
}
// Lines near the ellipse's top and bottom start at the curve, well inside its frame; beside
// the middle they don't. Measured against each line's own contour edge, so the check holds
// whatever line height the test machine's fonts give.
let line = |pred: &dyn Fn(f32) -> bool| *tight.iter().find(|l| pred(l.0) && l.2 > 60.0).unwrap();
for (what, l) in [("first line", line(&|top| top < 1.0)), ("near the bottom", line(&|top| top > 140.0 && top < 160.0))] {
let edge = contour(l.0, l.1).1;
assert!(l.2 <= edge + 3.0 && l.2 < 169.0 - 10.0, "{what} hugs the curve at {edge}: {l:?} in {tight:?}");
}
assert!(line(&|top| top > 65.0 && top < 80.0).2 >= 160.0, "beside the widest part: {tight:?}");
// A convex shape has no gaps for Through to use: it wraps as Tight.
assert_eq!(through, tight);
}
#[test]
fn contour_wrap_turns_with_the_object() {
use wordcraft_doc::para::{Float, ShapeKind, Wrap};
use wordcraft_doc::wrap::WrapPolygon;
// A wrap polygon covering the left half of a 200 × 100 frame.
let poly = WrapPolygon::new(true, [(0, 0), (10_800, 0), (10_800, 21_600), (0, 21_600)]).map(Arc::new);
// Where text goes on the first row: (left, right) of each line there.
let first_row = |wrap: Wrap, rot: f32, flip_h: bool| {
let f = Float { wrap, rot, flip_h, wrap_polygon: poly.clone(), ..Default::default() };
let l = line_spans(&lay(&wrapped_shape_doc(ShapeKind::Rectangle, 200.0, 100.0, f)));
l.iter().filter(|s| s.0 < 1.0).map(|s| (s.2.round(), s.3.round())).collect::<Vec<_>>()
};
// Unturned, text comes up to the polygon (half the frame) plus the distance from text.
assert_eq!(first_row(Wrap::Tight, 0.0, false)[0].0, 109.0);
// Turned half a turn or mirrored, the polygon covers the frame's right half: text goes to
// its left and past the frame.
for (rot, flip) in [(180.0, false), (0.0, true)] {
let row = first_row(Wrap::Tight, rot, flip);
assert_eq!((row[0].0, row[0].1, row[1].0), (0.0, 91.0, 209.0), "{rot} {flip}");
}
// Square wrapping ignores the polygon.
assert_eq!(first_row(Wrap::Square, 0.0, false)[0].0, 209.0);
}
/// Enclosed characters (#297, an `EQ \o\ac` field): the shape is drawn as a vector around the text,
/// whatever the font has; "shrink text" draws the text smaller inside a one-em shape, "enlarge
/// symbol" a bigger shape around text at its own size. Plain `\o` overstrikes its arguments.
#[test]
fn enclosed_characters_draw_a_shape_around_the_text() {
use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure};
let draw = |shape: EncloseShape, style: EncloseStyle| {
let mut d = Document::from_text("a b");
let e = Enclosure { shape, style, text: "A".into() };
d.insert_object(
&Pos::body(0, 2),
InlineObject::Field { instr: e.instr(), result: "A".into(), locked: false, code: false },
&Default::default(),
)
.unwrap();
let l = lay(&d);
let items = display::page_display(&d, &l.pages[0], &Default::default());
let shape = items
.iter()
.find_map(|i| if let display::Draw::Shape { rect, kind, stroke, .. } = i { Some((*rect, *kind, stroke.is_some())) } else { None });
// Each glyph's character, size and x.
let mut chars = Vec::new();
for i in &items {
if let display::Draw::Glyphs { text, size, glyphs, ranges, .. } = i {
for (g, r) in glyphs.iter().zip(ranges) {
chars.push((text.get(r.clone()).unwrap_or("").to_string(), *size, g.1));
}
}
}
let at = |c: &str| chars.iter().find(|x| x.0 == c).map(|x| (x.1, x.2)).unwrap_or_else(|| panic!("{c}: {items:?}"));
(shape.expect("a shape"), at("A"), at("b"))
};
let ((rect, kind, stroked), (size, gx), (base, bx)) = draw(EncloseShape::Circle, EncloseStyle::Shrink);
assert_eq!(kind, wordcraft_doc::para::ShapeKind::Ellipse);
assert!(stroked);
assert!(size < base * 0.8, "the text shrinks: {size} vs {base}");
assert!((rect.h - base * 1.05).abs() < 0.1 && (rect.w - rect.h).abs() < 0.1, "{rect:?}");
assert!(gx > rect.x && gx < rect.x + rect.w, "the text is inside: {gx} {rect:?}");
assert!(bx >= rect.x + rect.w, "the next text follows the shape: {bx} {rect:?}");
let ((rect, kind, _), (size, _), (base, _)) = draw(EncloseShape::Diamond, EncloseStyle::Enlarge);
assert_eq!(kind, wordcraft_doc::para::ShapeKind::Diamond);
assert!((size - base).abs() < 0.01 && rect.h > base * 1.5, "the shape grows: {rect:?}");
// A general overstrike: both arguments at the same place, no shape.
let mut d = Document::from_text("x");
d.insert_object(
&Pos::body(0, 1),
InlineObject::Field { instr: "eq \\o(=,/)".into(), result: String::new(), locked: false, code: false },
&Default::default(),
)
.unwrap();
let l = lay(&d);
let items = display::page_display(&d, &l.pages[0], &Default::default());
assert!(!items.iter().any(|i| matches!(i, display::Draw::Shape { .. })));
assert!(items.iter().any(|i| matches!(i, display::Draw::Glyphs { text, .. } if text.contains("=/"))), "{items:?}");
}
#[test]
fn ruby_text_sits_over_its_base_and_the_line_grows_to_fit() {
use wordcraft_doc::para::RubyAlign;
let plain = Document::from_text("Plain words here");
let mut d = Document::from_text("Plain here");
let ruby = InlineObject::Ruby {
base: "words".into(),
ruby: "a much longer reading".into(),
align: RubyAlign::Center,
size: 6.0,
raise: 12.0,
base_size: 12.0,
lang: String::new(),
props: Default::default(),
};
d.insert_object(&Pos::body(0, 6), ruby, &Default::default()).unwrap();
let height = |l: &DocLayout| {
l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.lines.first().map(|x| x.height) } else { None }).unwrap()
};
let (lp, lr) = (lay(&plain), lay(&d));
assert!(height(&lr) > height(&lp) + 3.0, "ruby line {} vs plain {}", height(&lr), height(&lp));
let items = display::page_display(&d, &lr.pages[0], &Default::default());
let glyphs_of = |want: &str| {
items.iter().find_map(|i| match i {
display::Draw::Glyphs { text, glyphs, size, .. } if text.contains(want) => Some((*size, glyphs.clone())),
_ => None,
})
};
let (_, line) = glyphs_of("Plain words").expect("base text drawn");
let (size, over) = glyphs_of("a much longer reading").expect("ruby text drawn");
let w = line[6]; // "Plain " then the base's first letter
// Smaller, 12 pt above the base text's baseline, and (being wider) centred over it: it starts
// at the base's slot, the base letters move in.
assert_eq!(size, 6.0);
assert!((w.2 - over[0].2 - 12.0).abs() < 0.01, "{w:?} {over:?}");
let slot = lr.caret(&Pos::body(0, 6)).unwrap().x;
assert!((over[0].1 - slot).abs() < 0.5 && w.1 > slot + 1.0, "{slot} {w:?} {over:?}");
// The plain text reads the base, and the caret steps over the ruby as one character.
assert_eq!(d.body[0].as_para().unwrap().plain_text(), "Plain words here");
let (a, b) = (lr.caret(&Pos::body(0, 6)).unwrap(), lr.caret(&Pos::body(0, 9)).unwrap());
assert!(b.x - a.x > 30.0, "{a:?} {b:?}");
}
/// #354: `wrapText`. Left: text only to the left of a centred object (Square or Tight); Right:
/// only to its right; Largest: only on its wider side.
#[test]
fn wrap_text_keeps_text_on_one_side() {
use wordcraft_doc::para::{Float, ShapeKind, Wrap, WrapText};
// A 100pt shape at `x` in the 468pt column; the lines beside it.
let beside = |kind, wrap, x: f32, side| {
let f = Float { wrap, wrap_text: side, x, ..Default::default() };
line_spans(&lay(&wrapped_shape_doc(kind, 100.0, 100.0, f))).into_iter().filter(|l| l.0 < 100.0).collect::<Vec<_>>()
};
for (kind, wrap) in [(ShapeKind::Rectangle, Wrap::Square), (ShapeKind::Ellipse, Wrap::Tight)] {
let both = beside(kind, wrap, 184.0, WrapText::BothSides);
assert!(both.iter().any(|l| l.2 < 1.0) && both.iter().any(|l| l.2 > 200.0), "{both:?}");
let left = beside(kind, wrap, 184.0, WrapText::Left);
// Nothing right of the object (Tight: lines come up to the outline, short of its middle).
let mid = if wrap == Wrap::Square { 175.0 } else { 234.0 };
assert!(!left.is_empty() && left.iter().all(|l| l.2 < 1.0 && l.3 <= mid + 0.01), "{wrap:?}: {left:?}");
let right = beside(kind, wrap, 184.0, WrapText::Right);
let mid = if wrap == Wrap::Square { 293.0 } else { 234.0 };
assert!(!right.is_empty() && right.iter().all(|l| l.2 >= mid - 0.01), "{wrap:?}: {right:?}");
}
// Nearer the left edge the right side is wider, nearer the right edge the left.
let near_left = beside(ShapeKind::Rectangle, Wrap::Square, 100.0, WrapText::Largest);
assert!(!near_left.is_empty() && near_left.iter().all(|l| l.2 >= 209.0 - 0.01), "{near_left:?}");
let near_right = beside(ShapeKind::Rectangle, Wrap::Square, 268.0, WrapText::Largest);
assert!(!near_right.is_empty() && near_right.iter().all(|l| l.2 < 1.0 && l.3 <= 259.0 + 0.01), "{near_right:?}");
}
#[test]
fn document_note_separators_and_continuation_notice() {
let mut d = Document::from_text(&"Body text line.\n".repeat(16));
let long = footnote(&mut d, &Pos::body(4, 4), &"A long footnote that runs on and on. ".repeat(100));
let sep = |cont: bool| {
let mut p = wordcraft_doc::Paragraph::new();
p.props.space_after = Some(0.0);
p.insert_object(0, InlineObject::note_separator(cont), &Default::default()).unwrap();
Some(vec![wordcraft_doc::para_block(p)])
};
let mut notice = wordcraft_doc::Paragraph::with_text("(continued)", Default::default());
notice.props.space_after = Some(0.0);
d.footnote_separators = wordcraft_doc::NoteSeparators {
separator: sep(false),
continuation_separator: sep(true),
continuation_notice: Some(vec![wordcraft_doc::para_block(notice)]),
};
let l = lay(&d);
let pieces = note_pieces(&l, long);
assert!(pieces.len() >= 2 && (pieces[0].0, pieces[1].0) == (0, 1), "{pieces:?}");
// The document's separators are paragraphs: their lines are drawn across the separator's
// place, short on page 1 and across the text on page 2.
let decor = |pi: usize| -> Vec<(f32, f32)> {
l.pages[pi]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story, y, para, l0, l1, .. } if *story == DECOR_STORY => Some((*y, item_bottom(*y, para, *l0, *l1).unwrap())),
_ => None,
})
.collect()
};
let (d0, d1) = (decor(0), decor(1));
assert_eq!((d0.len(), d1.len()), (2, 1), "separator and notice on page 1, continuation separator on page 2");
assert_eq!(rules_below(&l, 0, 0.0), vec![144.0]);
assert_eq!(rules_below(&l, 1, 0.0), vec![468.0]);
// The notice sits right under the part of the note on page 1, inside the page.
let note_bottom = l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Part(s), y, para, l0, l1, .. } if *s == long => item_bottom(*y, para, *l0, *l1),
_ => None,
})
.fold(0.0f32, f32::max);
let (ny, nb) = d0[1];
assert!((ny - note_bottom).abs() < 0.01, "notice at {ny}, note ends at {note_bottom}");
assert!(nb <= 720.01, "notice inside the page: {nb}");
// The separators and notice aren't text anyone clicks into.
assert_ne!(l.story_at(0, 80.0, ny + 2.0), Some(DECOR_STORY));
}
/// #344 with #332: a Drawing Canvas turns as a whole, like a group: its background and outline,
/// then its members clipped to its frame, all drawn inside its turn (the clip in the unturned
/// frame, so the clip turns too); floating or inline. Hit testing follows the turned canvas.
#[test]
fn rotated_canvas_draws_its_clipped_members_inside_the_turn() {
use crate::display::Draw;
use wordcraft_doc::para::{Anchor, CanvasStyle, Float, GroupChild, ShapeKind, Wrap};
let member = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 60.0,
h: 40.0,
fill: Some(wordcraft_doc::props::Rgb(1, 2, 3)),
stroke: None,
stroke_width: 0.0,
float: Float { rot: 10.0, ..Default::default() },
story: None,
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
let canvas = |float: Float| InlineObject::Group {
w: 200.0,
h: 100.0,
float,
ch_w: 200.0,
ch_h: 100.0,
children: vec![GroupChild { x: 20.0, y: 30.0, obj: member.clone() }],
canvas: Some(CanvasStyle { fill: Some(wordcraft_doc::props::Rgb(0xF0, 0xF4, 0xFF)), stroke: None, stroke_width: 0.0 }),
};
// Turned draws: (turn, background rect, clip rect, member rect and its own turn).
let turned = |draws: &[Draw]| -> (Vec<f32>, Vec<Rect>, Vec<(Rect, Vec<(Rect, f32)>)>) {
let (mut turns, mut bgs, mut clips) = (Vec::new(), Vec::new(), Vec::new());
for d in draws {
let Draw::Rotated { spin, items, .. } = d else { continue };
turns.push(spin.deg);
for i in items {
match i {
Draw::Shape { rect, fill: Some(c), .. } if c.0 == 0xF0 => bgs.push(*rect),
Draw::Clip { rect, items } => {
let members = items
.iter()
.map(|m| match m {
Draw::Rotated { spin, items, .. } => match items.first() {
Some(Draw::Shape { rect, .. }) => (*rect, spin.deg),
o => panic!("{o:?}"),
},
o => panic!("{o:?}"),
})
.collect();
clips.push((*rect, members));
}
_ => {}
}
}
}
(turns, bgs, clips)
};
for floating in [true, false] {
let float = if floating {
Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 100.0, y: 100.0, rot: 90.0, ..Default::default() }
} else {
Float { rot: 90.0, ..Default::default() }
};
let mut d = Document::from_text("Text by the canvas.");
d.insert_object(&Pos::body(0, 0), canvas(float), &Default::default()).unwrap();
let l = lay(&d);
let p = &l.pages[0];
let hit = l.object(&Pos::body(0, 0), 0).unwrap();
assert_eq!(hit.spin.deg, 90.0);
let f = hit.rect;
let (turns, bgs, clips) = turned(&crate::display::page_display(&d, p, &Default::default()));
assert!(turns.contains(&90.0), "floating {floating}: {turns:?}");
// Background and clip are the canvas's unturned frame; the member sits in it, unturned
// but for its own 10°.
assert_eq!(bgs.len(), 1, "floating {floating}");
assert!((bgs[0].x - f.x).abs() < 0.01 && (bgs[0].w - 200.0).abs() < 0.01, "{bgs:?} vs {f:?}");
assert_eq!(clips.len(), 1, "floating {floating}");
let (clip, members) = &clips[0];
assert!((clip.x - bgs[0].x).abs() < 0.01 && (clip.y - bgs[0].y).abs() < 0.01 && (clip.h - 100.0).abs() < 0.01, "{clip:?}");
assert_eq!(members.len(), 1, "{members:?}");
let (m, own) = members[0];
assert!((m.x - (clip.x + 20.0)).abs() < 0.01 && (m.y - (clip.y + 30.0)).abs() < 0.01, "{m:?} in {clip:?}");
assert_eq!(own, 10.0);
if floating {
// Turned a quarter, the canvas's frame's left end is empty; above its centre isn't.
let (cx, cy) = (f.x + f.w / 2.0, f.y + f.h / 2.0);
assert!(l.object_at(0, f.x + 5.0, cy, 0.0).is_none());
assert!(l.object_at(0, cx, cy - 80.0, 0.0).is_some());
}
}
}
#[test]
fn mirror_indents_swap_sides_on_even_pages() {
// #512: with mirrored indents the left indent is the inside one, on the right of an even page.
let mut d = Document::from_text("odd page\neven page");
let last = Pos::body(1, 0);
d.format_paragraphs(&Pos::body(0, 0), &last, &|p| {
p.indent_left = Some(72.0);
p.mirror_indents = Some(true);
})
.unwrap();
d.format_paragraphs(&last, &last, &|p| p.page_break_before = Some(true)).unwrap();
let l = lay(&d);
assert_eq!(l.pages.len(), 2);
let odd = l.caret(&Pos::body(0, 0)).unwrap();
let even = l.caret(&last).unwrap();
assert_eq!(even.page, 1);
assert!((odd.x - 144.0).abs() < 0.5, "odd page: inside indent on the left, {odd:?}");
assert!((even.x - 72.0).abs() < 0.5, "even page: the left side has the outside indent (0), {even:?}");
}
/// Ten short paragraphs in a two-column section (`widths`: unequal columns), then one paragraph
/// in a one-column section starting `start`.
fn two_column_section(start: wordcraft_doc::section::SectionStart, widths: Vec<(f32, f32)>) -> Document {
use wordcraft_doc::section::SectionProps;
let text: Vec<String> = (0..10).map(|i| format!("Short paragraph {i}")).chain(["After the break".to_string()]).collect();
let mut d = Document::from_text(&text.join("\n"));
let mut s = SectionProps::default();
s.columns.count = 2;
s.columns.widths = widths;
d.para_mut(StoryRef::Body, &Path::top(9)).unwrap().section = Some(Box::new(s));
d.last_section.start = start;
d
}
/// Each top-level paragraph's first piece: (page, x, top, bottom).
fn para_boxes(l: &DocLayout) -> Vec<(usize, f32, f32, f32)> {
let mut v: Vec<(usize, usize, f32, f32, f32)> = Vec::new();
for (pi, p) in l.pages.iter().enumerate() {
for it in &p.items {
if let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, x, y, .. } = it
&& let [b] = path.0[..]
&& !v.iter().any(|e| e.0 == b as usize)
{
v.push((b as usize, pi, *x, *y, item_bottom(*y, para, *l0, *l1).unwrap()));
}
}
}
v.sort_by_key(|e| e.0);
v.into_iter().map(|(_, pi, x, t, b)| (pi, x, t, b)).collect()
}
/// Bottoms of the two columns (paragraphs 0–9 by their x) and the top of paragraph 10.
fn column_bottoms(l: &DocLayout) -> (usize, f32, usize, f32, (usize, f32)) {
let b = para_boxes(l);
let x0 = b[0].1;
let (left, right): (Vec<_>, Vec<_>) = b[..10].iter().partition(|e| (e.1 - x0).abs() < 1.0);
let bottom = |v: &[&(usize, f32, f32, f32)]| v.iter().map(|e| e.3).fold(0.0f32, f32::max);
(left.len(), bottom(&left), right.len(), bottom(&right), (b[10].0, b[10].2))
}
#[test]
fn columns_before_a_continuous_break_are_balanced() {
use wordcraft_doc::section::SectionStart;
for widths in [vec![], vec![(150.0, 36.0), (282.0, 0.0)]] {
let l = lay(&two_column_section(SectionStart::Continuous, widths.clone()));
let (nl, bl, nr, br, (page, after)) = column_bottoms(&l);
// Filled left to right, level: five paragraphs each (the left one may hold one more).
assert!(nl == 5 || nl == 6, "{widths:?}: {nl} left, {nr} right");
assert!(bl >= br - 0.5 && bl - br < 20.0, "{widths:?}: columns end at {bl} and {br}");
// The next section starts right below, on the same page.
assert_eq!(page, 0);
assert!(after >= bl - 0.5 && after < bl + 30.0, "{widths:?}: next section at {after}, columns end at {bl}");
}
}
#[test]
fn columns_before_a_page_break_or_a_column_break_are_not_balanced() {
use wordcraft_doc::section::SectionStart;
// Next-page break: the ten paragraphs fill the first column; the next section is on page 2.
let l = lay(&two_column_section(SectionStart::NextPage, vec![]));
let (nl, _, nr, _, (page, _)) = column_bottoms(&l);
assert_eq!((nl, nr, page), (10, 0, 1));
// An explicit column break after paragraph 1 decides where the first column ends.
let mut d = two_column_section(SectionStart::Continuous, vec![]);
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
let end = p.text.len();
p.insert_text(end, &wordcraft_doc::para::COLUMN_BREAK.to_string(), &Default::default()).unwrap();
let l = lay(&d);
let (nl, bl, nr, br, (page, after)) = column_bottoms(&l);
assert_eq!((nl, nr, page), (2, 8, 0));
// The next section starts below the taller column.
assert!(br > bl + 50.0 && after >= br - 0.5, "columns end at {bl} and {br}, next section at {after}");
}
/// The glyphs of each note reference mark in the body, by note id.
fn note_marks(l: &DocLayout) -> HashMap<u32, Vec<u32>> {
let mut marks = HashMap::new();
for it in l.pages.iter().flat_map(|p| p.items.iter()) {
if let Placed::Lines { story: StoryRef::Body, para, .. } = it {
for (ci, id) in &para.notes {
let c = &para.clusters[*ci];
marks.insert(*id, para.glyphs[c.g0 as usize..c.g1 as usize].iter().map(|g| g.gid).collect::<Vec<_>>());
}
}
}
marks
}
#[test]
fn footnotes_restart_on_each_page_and_section() {
use wordcraft_doc::section::{NoteProps, NoteRestart};
let mut d = Document::from_text(&"Body text line.\n".repeat(120));
let a = footnote(&mut d, &Pos::body(2, 4), "First note.");
let b = footnote(&mut d, &Pos::body(4, 4), "Second note.");
let c = footnote(&mut d, &Pos::body(110, 4), "A later page's note.");
let l = lay(&d);
let page = |l: &DocLayout, id: u32| l.caret(&d.start_of(StoryRef::Part(id))).unwrap().page;
assert!(page(&l, c) > page(&l, a), "the third note is on a later page");
let marks = note_marks(&l);
assert_ne!(marks[&c], marks[&a], "numbered on: 3 is not 1");
// Restart each page: the later page's note is 1 again; the first page counts 1, 2.
d.settings.footnote_pr = NoteProps { num_restart: Some(NoteRestart::EachPage), ..Default::default() };
let marks = note_marks(&lay(&d));
assert_eq!(marks[&c], marks[&a]);
assert_ne!(marks[&b], marks[&a]);
// Restart each section, starting at 5, in a second section that starts at paragraph 50.
d.settings.footnote_pr = NoteProps::default();
d.para_mut(StoryRef::Body, &Path::top(49)).unwrap().section = Some(Box::new(SectionProps::default()));
d.last_section.footnote_pr = NoteProps { num_restart: Some(NoteRestart::EachSect), num_start: Some(5), ..Default::default() };
let nums = note_numbers(&d, false);
assert_eq!((nums[&a], nums[&b], nums[&c]), (1, 2, 5));
}
#[test]
fn endnotes_at_section_end_and_footnotes_below_text() {
use wordcraft_doc::section::{NotePos, NoteProps};
let mut d = Document::from_text("Alpha\nBeta\nGamma\nDelta");
let eid = d.add_part(
wordcraft_doc::PartKind::Endnote,
vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("An endnote.", Default::default()))],
);
d.insert_object(
&Pos::body(0, 5),
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Endnote, id: eid, custom: String::new() },
&Default::default(),
)
.unwrap();
// Two sections, the second on a new page.
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section = Some(Box::new(SectionProps::default()));
let end_page = |d: &Document| {
let l = lay(d);
(l.caret(&d.start_of(StoryRef::Part(eid))).unwrap().page, l.caret(&Pos::body(2, 0)).unwrap().page)
};
assert_eq!(end_page(&d), (1, 1), "end of document: after the second section's text");
d.settings.endnote_pr = NoteProps { pos: Some(NotePos::SectEnd), ..Default::default() };
assert_eq!(end_page(&d), (0, 1), "end of section: on the first section's page");
// A footnote below the text sits right under the last line, above where it'd be at the bottom.
let fid = footnote(&mut d, &Pos::body(0, 2), "A footnote.");
let top = |d: &Document| lay(d).caret(&d.start_of(StoryRef::Part(fid))).unwrap().top;
let bottom = top(&d);
d.settings.footnote_pr = NoteProps { pos: Some(NotePos::BeneathText), ..Default::default() };
let below = top(&d);
let text = lay(&d).caret(&Pos::body(1, 0)).unwrap();
assert!(below > text.top + text.height && below < bottom - 100.0, "{below} {bottom} {text:?}");
}
/// Each run is hyphenated and proofed in its own language (`w:lang`, resolved through the
/// styles): Polish patterns and dictionary for Polish runs, English ones for English runs.
#[test]
fn hyphenation_and_proofing_follow_each_runs_language() {
// A tiny synthetic Polish dictionary (real ones are installed on the system, not bundled).
wordcraft_proof::pl::install_dictionary("SET UTF-8\nSFX B Y 1\nSFX B y ego y\n", "2\nkonstytucyjny/B\npłynie\n").unwrap();
let text = "Konstytucyjnego żeka typography beautifull";
let mut d = Document::from_text(text);
d.settings.auto_hyphenation = true;
let eng = text.find("typography").unwrap();
d.format_range(&Pos::body(0, 0), &Pos::body(0, eng), &|c| c.lang = Some("pl-PL".into())).unwrap();
d.format_range(&Pos::body(0, eng), &Pos::body(0, text.len()), &|c| c.lang = Some("en-US".into())).unwrap();
let mut c = LayoutCache::new();
let l = layout(&d, &mut c, &LayoutOptions { proofing: true, ..LayoutOptions::default() });
let para = l.pages[0].items.iter().find_map(|i| if let Placed::Lines { para, .. } = i { Some(para.clone()) } else { None }).expect("a paragraph");
// Byte offsets after which a hyphen may go.
let ends: Vec<usize> = para.hyph_after.iter().filter_map(|&k| para.clusters.get(k as usize).map(|c| c.end)).collect();
for prefix in ["Kon", "Konsty", "Konstytu", "Konstytucyj", "Konstytucyjne"] {
assert!(ends.contains(&prefix.len()), "Polish kon-sty-tu-cyj-ne-go: no break after {prefix:?} in {ends:?}");
}
for prefix in ["ty", "typog", "typogra"] {
assert!(ends.contains(&(eng + prefix.len())), "English ty-pog-ra-phy: no break after {prefix:?} in {ends:?}");
}
// Spelling: the Polish misspelling and the English one; Polish words aren't English errors.
let flagged: Vec<&str> = para.issues.iter().filter(|i| !i.2).map(|i| &text[i.0..i.1]).collect();
assert_eq!(flagged, ["żeka", "beautifull"]);
// The layout and the Review commands report the same issues.
let p = d.para(StoryRef::Body, &Path::top(0)).unwrap();
let issues = para::paragraph_issues(p, &para::proof_runs(&d, p, None), &Default::default(), true);
assert_eq!(issues.iter().map(|i| (i.start, i.end, i.kind == wordcraft_proof::IssueKind::Grammar)).collect::<Vec<_>>(), para.issues);
assert_eq!(issues[0].lang, wordcraft_proof::ProofLang::Pl);
assert_eq!(issues[1].lang, wordcraft_proof::ProofLang::En);
}
/// Text in a language WordCraft can't proof gets no spelling marks, and "Do not check spelling
/// or grammar" text none either; the document default language applies to text without its own.
#[test]
fn unproofed_languages_and_no_proof_runs_are_not_marked() {
// A tiny synthetic Polish dictionary (real ones are installed on the system, not bundled).
wordcraft_proof::pl::install_dictionary("SET UTF-8\nSFX B Y 1\nSFX B y ego y\n", "2\nkonstytucyjny/B\npłynie\n").unwrap();
let opts = LayoutOptions { proofing: true, ..LayoutOptions::default() };
let issues = |d: &Document| -> usize {
let l = layout(d, &mut LayoutCache::new(), &opts);
l.pages[0].items.iter().map(|i| if let Placed::Lines { para, .. } = i { para.issues.len() } else { 0 }).sum()
};
let text = "Nous écrivons beaucoup aujourd'hui.";
let mut d = Document::from_text(text);
assert!(issues(&d) > 0, "French words are English misspellings in an English document");
d.styles.default_chr.lang = Some("fr-FR".into());
assert_eq!(issues(&d), 0, "no French dictionary: no marks");
d.styles.default_chr.lang = Some("pl-PL".into());
let mut pl = Document::from_text("Płynie żeka.");
pl.styles.default_chr.lang = Some("pl-PL".into());
assert_eq!(issues(&pl), 1);
pl.format_range(&Pos::body(0, 0), &Pos::body(0, "Płynie żeka.".len()), &|c| c.no_proof = Some(true)).unwrap();
assert_eq!(issues(&pl), 0);
}
/// #451: page totals in the body show the document's page count (the stale cached result is
/// ignored), on every page, like the same fields in the footer.
#[test]
fn body_page_totals_count_every_page() {
let mut d = Document::from_text("Body-1\nBody-2\nBody-3");
let field = |instr: &str| InlineObject::Field { instr: instr.into(), result: "9".into(), locked: false, code: false };
for i in 0..3 {
let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap();
p.props.page_break_before = Some(i > 0);
let n = p.len();
p.insert_object(n, field("NUMPAGES"), &Default::default()).unwrap();
let n = p.len();
p.insert_object(n, field("SECTIONPAGES"), &Default::default()).unwrap();
}
let mut fp = wordcraft_doc::Paragraph::new();
fp.insert_object(0, field("NUMPAGES"), &Default::default()).unwrap();
let id = d.add_part(wordcraft_doc::PartKind::Footer, vec![wordcraft_doc::para_block(fp)]);
d.last_section.footers.default = Some(id);
let l = lay(&d);
assert_eq!(l.pages.len(), 3);
let shown = |items: &[Placed]| -> Vec<String> {
items
.iter()
.flat_map(|it| if let Placed::Lines { para, .. } = it { para.shown.iter().map(|s| s.1.clone()).collect() } else { vec![] })
.collect()
};
for (i, p) in l.pages.iter().enumerate() {
assert_eq!(shown(&p.items), ["3", "3"], "body of page {}", i + 1);
assert_eq!(shown(&p.footer), ["3"], "footer of page {}", i + 1);
}
// One page: one pass, total 1.
let mut d1 = Document::from_text("Only");
let p = d1.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.insert_object(4, field("NUMPAGES"), &Default::default()).unwrap();
assert_eq!(shown(&lay(&d1).pages[0].items), ["1"]);
}
/// #450: a footnote referenced in a table cell is placed at the bottom of its page, like one in
/// a body paragraph.
#[test]
fn table_cell_footnotes_are_placed() {
let mut d = Document::from_text("After the table.");
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(wordcraft_doc::Table::new(1, 1, 300.0))).unwrap();
let cell = Pos { story: StoryRef::Body, path: Path(vec![0, 0, 0, 0]), off: 0 };
d.para_mut(StoryRef::Body, &cell.path).unwrap().insert_text(0, "Table reference", &Default::default()).unwrap();
let id = footnote(&mut d, &Pos { off: "Table reference".len(), ..cell }, "Table note payload.");
let l = lay(&d);
assert_eq!(l.pages.len(), 1);
let c = l.caret(&d.start_of(StoryRef::Part(id))).expect("the cell's footnote is placed");
assert_eq!(c.page, 0);
let body = l.caret(&Pos::body(1, 0)).unwrap();
assert!(c.top > body.top + 100.0, "at the page bottom: {c:?} {body:?}");
}
#[test]
fn east_asian_text_takes_the_asian_font_and_latin_text_the_latin_font() {
// #505: one run with a Latin font and an Asian font (w:eastAsia) of its own sets each script in
// its font. Prefer an installed CJK font other than the one fallback would pick anyway.
let resolve = |f: &str| wordcraft_fonts::word::resolve(f, false, false);
let fallback = wordcraft_fonts::FontDb::global().fallback_for('你', 0).map(|f| f.family.clone());
let mut fonts: Vec<String> = wordcraft_fonts::FontDb::global()
.east_asian_families()
.into_iter()
.filter(|f| {
let r = resolve(f);
!r.substituted && r.face.covers('你') && r.face.covers('好')
})
.collect();
fonts.sort_by_key(|f| Some(f) == fallback.as_ref());
let Some(asian) = fonts.first().cloned() else {
eprintln!("skipped: no Chinese font installed");
return;
};
let latin = wordcraft_doc::styles::BODY_FONT;
let text = "Hello 你好 World";
let mut d = Document::from_text(text);
d.format_range(&Pos::body(0, 0), &Pos::body(0, text.len()), &|c| {
c.font = Some(latin.into());
c.font_ea = Some(asian.clone());
})
.unwrap();
let l = lay(&d);
let para =
l.pages.iter().flat_map(|p| p.items.iter()).find_map(|it| if let Placed::Lines { para, .. } = it { Some(para) } else { None }).unwrap();
let (latin_face, asian_face) = (resolve(latin).face.id(), resolve(&asian).face.id());
for c in &para.clusters {
let Some(ch) = text.get(c.start..c.end).and_then(|s| s.chars().next()).filter(|ch| !ch.is_whitespace()) else { continue };
let face = para.styles[c.style as usize].face.id();
let want = if ch.is_ascii() { latin_face } else { asian_face };
assert_eq!(face, want, "{ch:?} in {}", if ch.is_ascii() { latin } else { &asian });
}
}
/// A page total in the footer ("Page X of Y") is filled in after pagination, so it doesn't make
/// the body lay out a second time; one in the body still does.
#[test]
fn footer_page_total_needs_one_body_pass() {
let field = || InlineObject::Field { instr: "NUMPAGES".into(), result: "9".into(), locked: false, code: false };
let mut d = Document::from_text("Body-1\nBody-2\nBody-3");
for i in 1..3 {
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.page_break_before = Some(true);
}
let mut fp = wordcraft_doc::Paragraph::new();
fp.insert_object(0, field(), &Default::default()).unwrap();
let id = d.add_part(wordcraft_doc::PartKind::Footer, vec![wordcraft_doc::para_block(fp)]);
d.last_section.footers.default = Some(id);
let first = |d: &Document| {
let totals = Totals::first_guess(d.sections().len());
layout_pass(d, &mut LayoutCache::new(), &LayoutOptions::default(), &HashMap::new(), &totals, true)
};
assert!(matches!(first(&d), Pass::Done(l) if l.pages.len() == 3), "the footer's total asked for another pass");
// The same field in the body is only known once every page is laid out.
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
let n = p.len();
p.insert_object(n, field(), &Default::default()).unwrap();
assert!(matches!(first(&d), Pass::Again(t) if t.pages == 3));
}
/// Bulgarian text (`w:lang` `bg-BG`) is hyphenated by the Bulgarian syllable rules; the same
/// Cyrillic text in an English run is not hyphenated (the English data has nothing to say about it).
#[test]
fn bulgarian_text_is_hyphenated_by_its_own_rules() {
let text = "Сричкопренасянето в професионалните документи задължително следва правописните правила, разработени от специалистите. ".repeat(6);
let hyphenated = |d: &Document| -> Vec<(String, String)> {
let mut out = Vec::new();
for it in &lay(d).pages[0].items {
if let Placed::Lines { para, .. } = it {
for l in para.lines.iter().filter(|x| x.hyphen.is_some()) {
let end = l.stop;
let before = text.get(..end).unwrap_or_default();
let word_start = before.rfind(' ').map_or(0, |i| i + 1);
let after = text.get(end..).unwrap_or_default();
let word_end = after.find(|c: char| !c.is_alphabetic()).unwrap_or(after.len());
out.push((text[word_start..end].to_string(), after[..word_end].to_string()));
}
}
}
out
};
let mut d = Document::from_text(&text);
d.settings.auto_hyphenation = true;
assert!(hyphenated(&d).is_empty(), "Cyrillic in an English run: {:?}", hyphenated(&d));
d.format_range(&Pos::body(0, 0), &Pos::body(0, text.len()), &|c| c.lang = Some("bg-BG".into())).unwrap();
let splits = hyphenated(&d);
assert!(!splits.is_empty(), "no hyphenated lines");
let lim = wordcraft_proof::hyphen::Limits::default();
for (head, tail) in &splits {
let word = format!("{head}{tail}");
let at = head.chars().count();
assert!(wordcraft_proof::hyphen::hyphen_points_in(&word, &lim, wordcraft_proof::ProofLang::Bg).contains(&at), "{head}-{tail}");
}
}
/// Spaces and tabs never make a line taller in Word, whatever their size (measured in Word for
/// Windows: 9 pt text with 11–20 pt tabs, spaces and ideographic spaces kept the 9 pt line; a
/// line of only big spaces took the paragraph mark's height; a big letter or no-break space did
/// grow it). Tables of contents with big tab runs were a third taller than Word's.
#[test]
fn spaces_and_tabs_do_not_make_a_line_taller() {
use wordcraft_doc::props::CharProps;
let small = CharProps { size: Some(9.0), ..Default::default() };
let big = CharProps { size: Some(20.0), ..Default::default() };
let pitch = |middle: &str, middle_props: &CharProps| {
let mut d = Document::new();
d.body = (0..3)
.map(|i| {
let mut p = wordcraft_doc::Paragraph::with_text(&format!("L{i}A"), small.clone());
let n = p.len();
p.insert_text(n, middle, middle_props).unwrap();
let n = p.len();
p.insert_text(n, "B", &small).unwrap();
p.props = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
p.mark = small.clone();
wordcraft_doc::para_block(p)
})
.collect();
let l = lay(&d);
l.caret(&Pos::body(1, 0)).unwrap().top - l.caret(&Pos::body(0, 0)).unwrap().top
};
let plain = pitch(" ", &small);
for (what, middle) in [("a tab", "\t"), ("a space", " "), ("two spaces", " "), ("an ideographic space", "\u{3000}")] {
assert!((pitch(middle, &big) - plain).abs() < 0.01, "{what} at 20 pt: {} vs {plain}", pitch(middle, &big));
}
assert!(pitch("x", &big) > plain + 5.0, "a 20 pt letter does make it taller");
assert!(pitch("\u{A0}", &big) > plain + 5.0, "so does a no-break space");
}
/// A list label taller than its text makes the first line taller above the baseline only: Word
/// keeps the text's descent below it (measured in Word for Windows, `plan/word` R18: a Symbol
/// bullet's taller ascent counts, a Courier New label's deeper descent doesn't). NIH's bulleted
/// items each came out a fifth of a point taller, enough to push a paragraph's last lines over
/// the page bottom.
#[test]
fn a_list_label_adds_ascent_but_not_descent() {
let first_line = |label_size: Option<f32>| {
let mut d = Document::new();
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
for a in &mut d.numbering.abstracts {
for l in &mut a.levels {
l.chr.size = label_size;
}
}
let ten = wordcraft_doc::props::CharProps { size: Some(10.0), ..Default::default() };
let mut p = wordcraft_doc::Paragraph::with_text("Item", ten.clone());
p.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
p.mark = ten;
p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.0));
d.body = vec![wordcraft_doc::para_block(p)];
let l = lay(&d);
l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.lines.first().cloned() } else { None }).unwrap()
};
let plain = first_line(None);
let big = first_line(Some(30.0));
let below = |l: &crate::para::Line| l.top + l.height - l.baseline;
assert!(big.baseline - big.top > plain.baseline - plain.top + 5.0, "the big label raises the baseline");
assert!((below(&big) - below(&plain)).abs() < 0.01, "the text's descent: {} vs {}", below(&big), below(&plain));
}
/// A cell's hidden end-of-cell mark (`w:hideMark`), measured in Word for Windows (`plan/word`
/// R20, 9 cases): its empty last paragraph takes no room, spacing and all, so a row of such empty
/// cells is only its borders; one ordinary empty cell keeps the row a line tall, and a paragraph
/// with text is never hidden. NIH's Table A showed a blank row Word doesn't.
#[test]
fn a_hidden_cell_mark_takes_no_room() {
let row3_top = |hide: [bool; 2], second: Option<&str>| {
let mut d = Document::from_text("Above");
let mut t = Table::new(3, 2, 300.0);
for (r, text) in [(0, "R1"), (2, "R3")] {
for c in 0..2 {
t.rows[r].cells[c].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
}
}
for (cell, hide) in t.rows[1].cells.iter_mut().zip(hide) {
cell.props.hide_mark = hide;
let mut blocks = Vec::new();
if let Some(text) = second {
blocks.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default())));
}
let mut mark = wordcraft_doc::Paragraph::with_text("", Default::default());
mark.props.space_before = Some(10.0);
blocks.push(wordcraft_doc::para_block(mark));
cell.blocks = blocks;
}
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let at = |r: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, r, 0, 0]), off: 0 }).unwrap().top;
// Rows 1 and 2 together.
at(2) - at(0)
};
// Row 1 is one line of text; row 2's height is what's left.
let line = {
let both = row3_top([false, false], Some("Tx"));
let mut d = Document::from_text("Above");
let mut t = Table::new(2, 1, 300.0);
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("R1", Default::default()))];
t.rows[1].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("R3", Default::default()))];
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let at = |r: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, r, 0, 0]), off: 0 }).unwrap().top;
assert!(both > at(1) - at(0));
at(1) - at(0)
};
let row2 = |hide: [bool; 2], second: Option<&str>| row3_top(hide, second) - line;
let hidden = row2([true, true], None);
assert!(hidden < 1.5, "a row of hidden marks is only its borders: {hidden} vs a line of {line}");
let mixed = row2([true, false], None);
let plain = row2([false, false], None);
assert!(plain > 5.0 && (mixed - plain).abs() < 0.01, "an ordinary empty cell keeps the row: {mixed} vs {plain}");
let text = row2([true, true], Some("Tx"));
assert!((text - line).abs() < 0.5, "text stays, the empty mark after it goes: {text} vs {line}");
}
/// A text frame in a header, footer or cell (NIH's footers put the page number in one, centred,
/// with the footer text wrapping around it) is placed apart: it takes no room in the flow, sits
/// where its alignment says, and the text beside it keeps clear of it. WordCraft laid it out as
/// an ordinary paragraph, a line of its own above the footer text.
#[test]
fn a_text_frame_in_a_cell_floats_beside_the_text() {
use wordcraft_doc::props::TextFrame;
let mut d = Document::from_text("Above");
let mut t = Table::new(1, 1, 300.0);
let mut framed = wordcraft_doc::Paragraph::with_text("7", Default::default());
framed.props.frame = Some(TextFrame {
x_align: Some("right".into()),
h_anchor: "text".into(),
v_anchor: "text".into(),
wrap: "around".into(),
..Default::default()
});
let text = wordcraft_doc::Paragraph::with_text("Cell text", Default::default());
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(framed), wordcraft_doc::para_block(text)];
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let at = |i: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, i]), off: 0 }).unwrap();
let (frame, text) = (at(0), at(1));
assert!((frame.top - text.top).abs() < 0.5, "the frame takes no room: {} vs {}", frame.top, text.top);
assert!(frame.x > text.x + 200.0, "right-aligned in the cell: {} vs {}", frame.x, text.x);
}
/// Where a list paragraph's text starts when its style has indents of its own (measured in Word,
/// `plan/word` R16): the list level's indents go on top of the most derived style that gives the
/// list, so that style's own indent (and those of styles based on it) win, its base styles' don't
/// and the paragraph's own list wins over every style; a paragraph that switches the list off
/// (`num: 0`) drops the styles' indents. NIH's Heading 3 (its own 0.9" on Heading 2's list) sat at
/// the list's 0.6", and its "sample text" paragraphs with the list off at 0.9" instead of the margin.
#[test]
fn list_indents_and_style_indents_combine_like_word() {
use wordcraft_doc::props::NumRef;
use wordcraft_doc::styles::{Style, StyleKind};
let text_x = |style: &str, own: ParaProps| {
let mut d = Document::new();
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
let style_of = |id: &str, based: Option<&str>, para: ParaProps| Style {
id: id.into(),
name: id.into(),
kind: StyleKind::Paragraph,
based_on: based.map(Into::into),
para,
..Default::default()
};
let list = ParaProps { numbering: Some(NumRef { num, level: 0 }), ..Default::default() };
let ind = ParaProps { indent_left: Some(64.8), ..Default::default() };
// List and indent in one style; list in the base, indent in the derived one; the reverse.
d.styles.upsert(style_of("Both", None, ParaProps { indent_left: Some(64.8), ..list.clone() }));
d.styles.upsert(style_of("ListBase", None, list.clone()));
d.styles.upsert(style_of("IndentOnList", Some("ListBase"), ind.clone()));
d.styles.upsert(style_of("IndentBase", None, ind));
d.styles.upsert(style_of("ListOnIndent", Some("IndentBase"), list.clone()));
let own =
ParaProps { style: Some(style.into()), numbering: own.numbering.map(|n| NumRef { num: if n.num == 0 { 0 } else { num }, ..n }), ..own };
let mut p = wordcraft_doc::Paragraph::with_text("Text", Default::default());
p.props = own;
d.body = vec![wordcraft_doc::para_block(p)];
lay(&d).caret(&Pos::body(0, 0)).unwrap().x
};
let listed = || ParaProps { numbering: Some(NumRef { num: 1, level: 0 }), ..Default::default() };
let off = || ParaProps { numbering: Some(NumRef { num: 0, level: 0 }), ..Default::default() };
let cases = [
("the style's own indent beats its list's", text_x("Both", ParaProps::default()), 72.0 + 64.8),
("a style based on the list's style wins too", text_x("IndentOnList", ParaProps::default()), 72.0 + 64.8),
("the list's style beats its base style", text_x("ListOnIndent", ParaProps::default()), 108.0),
("the paragraph's own list beats every style", text_x("Both", listed()), 108.0),
("list off: the style's indent goes too", text_x("Both", off()), 72.0),
("list off drops an indent from any style", text_x("IndentBase", off()), 72.0),
("list off keeps the paragraph's own indent", text_x("Both", ParaProps { indent_left: Some(10.0), ..off() }), 82.0),
];
for (what, got, want) in cases {
assert!((got - want).abs() < 0.5, "{what}: {got} vs {want}");
}
}
/// Keep with next at a page bottom, measured in Word for Windows (`plan/word` R19, 20 cases): a
/// paragraph that would fit on the page by itself moves over with the next one, but one that
/// doesn't fit anyway breaks as usual, its last lines going over with the next paragraph (with
/// Keep lines together it moves whole); a chain of kept paragraphs moves together. NIH's
/// five-line kept paragraph left a third of a page empty.
#[test]
fn keep_with_next_moves_a_paragraph_only_when_it_would_fit() {
// Page of each of the kept paragraph's 5 lines and of the first paragraph after it.
let pages = |fillers: usize, kept: ParaProps, next: ParaProps| {
let mut d = Document::new();
let plain = ParaProps {
space_before: Some(0.0),
space_after: Some(0.0),
line_spacing: Some(wordcraft_doc::props::LineSpacing::Multiple(1.0)),
..Default::default()
};
let para = |text: &str, p: &ParaProps| {
let mut x = wordcraft_doc::Paragraph::with_text(text, Default::default());
x.props = ParaProps { keep_next: p.keep_next, keep_lines: p.keep_lines, ..plain.clone() };
wordcraft_doc::para_block(x)
};
d.body = (0..fillers).map(|i| para(&format!("F{i}"), &plain)).collect();
d.body.push(para("Ka\nKb\nKc\nKd\nKe", &kept));
d.body.push(para("NX", &next));
d.body.push(para("NY", &plain));
let l = lay(&d);
let k = fillers;
[0, 3, 6, 9, 12]
.map(|off| l.caret(&Pos::body(k, off)).unwrap().page)
.into_iter()
.chain([l.caret(&Pos::body(k + 1, 0)).unwrap().page])
.collect::<Vec<_>>()
};
let none = ParaProps::default();
let kn = ParaProps { keep_next: Some(true), ..Default::default() };
let kl = ParaProps { keep_lines: Some(true), ..kn.clone() };
// Find where the kept paragraph just fits: its last line on page 1, the next one's on page 2.
let fit = (20..80).find(|n| pages(*n, none.clone(), none.clone()) == [0, 0, 0, 0, 0, 1]).expect("a filler count that fills the page");
assert_eq!(pages(fit, kn.clone(), none.clone()), [1, 1, 1, 1, 1, 1], "fits by itself: moves with the next");
let split = pages(fit + 1, none.clone(), none.clone());
assert!(split[0] == 0 && split[4] == 1, "one more line splits it: {split:?}");
assert_eq!(pages(fit + 1, kn.clone(), none.clone()), split, "doesn't fit anyway: breaks as usual");
assert_eq!(pages(fit + 1, kl.clone(), none.clone()), [1, 1, 1, 1, 1, 1], "with keep lines together it moves whole");
assert_eq!(pages(fit - 1, kn.clone(), kn.clone()), [1, 1, 1, 1, 1, 1], "a kept chain moves together");
}
/// An empty paragraph that only carries a section break before a continuous section takes no
/// room, measured in Word for Windows (`plan/word` R21, 10 cases): the text after it follows right
/// on, and columns balanced above it don't count it (two one-line paragraphs in two columns sit
/// side by side). A section break on a paragraph with text keeps its line, and so does any break
/// before a section on a new page. The SARP form's fields drifted a line lower at each of its
/// many continuous breaks.
#[test]
fn an_empty_continuous_section_mark_takes_no_room() {
use wordcraft_doc::section::{SectionProps, SectionStart};
// "Pa", the section's last paragraph (`mark`), "Pb", "Pc" in the next section of `cols`
// columns, then "Pd" after a second break.
let tops = |mark: &str, cols: u32, start: SectionStart| {
let mut d = Document::from_text(&format!("Pa\n{mark}\nPb\nPc\n\nPd"));
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section =
Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() }));
let mut s = SectionProps { start, ..Default::default() };
s.columns.count = cols;
d.para_mut(StoryRef::Body, &Path::top(4)).unwrap().section = Some(Box::new(s));
d.last_section.start = SectionStart::Continuous;
let l = lay(&d);
[0, 2, 3, 5].map(|i| {
let c = l.caret(&Pos::body(i, 0)).unwrap();
(c.page, (c.top * 10.0).round() / 10.0, c.x)
})
};
let one = tops("", 1, SectionStart::Continuous);
let line = one[2].1 - one[1].1;
assert!((one[1].1 - one[0].1 - line).abs() < 0.15, "Pb right under Pa: {one:?}");
assert!((one[3].1 - one[2].1 - line).abs() < 0.15, "Pd right under Pc: {one:?}");
let text = tops("Mark", 1, SectionStart::Continuous);
assert!((text[1].1 - text[0].1 - 2.0 * line).abs() < 0.15, "a mark with text keeps its line: {text:?}");
let two = tops("", 2, SectionStart::Continuous);
assert_eq!(two[1].1, two[2].1, "balanced: Pb and Pc side by side: {two:?}");
assert!(two[2].2 > two[1].2 + 100.0, "Pc in the second column: {two:?}");
assert!((two[3].1 - two[1].1 - line).abs() < 0.15, "Pd a line below them: {two:?}");
let new_page = tops("", 1, SectionStart::NextPage);
assert_eq!(new_page[1].0, 1, "a next-page section starts a page: {new_page:?}");
}
/// A floating object positioned relative to its paragraph is measured from the paragraph's start
/// above its space before, measured in Word for Windows (`plan/word` R22, 10 cases: 0, 10 and 20
/// pt before, offsets 0 and −7.2 pt; the previous paragraph's space after still counts). The SARP
/// form's field boxes sat a space-before too low, over their labels.
#[test]
fn paragraph_relative_floats_start_above_the_space_before() {
let shape_top = |before: f32, prev_after: f32, y: f32| {
let float = wordcraft_doc::para::Float {
wrap: wordcraft_doc::para::Wrap::InFrontOfText,
h_rel: wordcraft_doc::para::Anchor::Column,
v_rel: wordcraft_doc::para::Anchor::Paragraph,
y,
..Default::default()
};
let shape = InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w: 20.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
let plain = ParaProps { space_before: Some(0.0), space_after: Some(prev_after), ..Default::default() };
let mut first = wordcraft_doc::Paragraph::with_text("First", Default::default());
first.props = plain;
let mut p = wordcraft_doc::Paragraph::with_text("Text ", Default::default());
p.text.push(wordcraft_doc::para::OBJ);
p.objects = vec![shape];
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: Default::default() }];
p.props = ParaProps { space_before: Some(before), space_after: Some(0.0), ..Default::default() };
let mut d = Document::from_text("");
d.body = vec![wordcraft_doc::para_block(first), wordcraft_doc::para_block(p)];
let l = lay(&d);
let first_bottom = {
let c = l.caret(&Pos::body(0, 0)).unwrap();
c.top + c.height
};
let top = l.pages[0].items.iter().find_map(|i| if let Placed::Shape { rect, .. } = i { Some(rect.y) } else { None }).unwrap();
(top, first_bottom)
};
for before in [0.0, 10.0, 20.0] {
for y in [0.0, -7.2] {
let (top, first_bottom) = shape_top(before, 0.0, y);
assert!((top - (first_bottom + y)).abs() < 1.0, "{before} pt before, offset {y}: {top} vs {}", first_bottom + y);
}
}
let (top, first_bottom) = shape_top(0.0, 10.0, 0.0);
assert!((top - (first_bottom + 10.0)).abs() < 1.0, "below the previous paragraph's space after: {top} vs {first_bottom}");
}
/// Unequal columns (a narrow label column and a wide text column, as in NIH's SARP form): text
/// after a column break is broken for the column it lands in. WordCraft broke it for the narrow
/// column it started in, so a paragraph took a dozen lines a word or two wide.
#[test]
fn text_after_a_column_break_takes_the_new_column_s_width() {
let mut d = Document::new();
d.last_section.columns = wordcraft_doc::section::Columns { count: 2, space: 36.0, separator: false, widths: vec![(67.0, 52.0), (348.0, 0.0)] };
let brk = wordcraft_doc::para::COLUMN_BREAK;
let text = format!("Label{brk}Provide a description of the planned recruitment methods including use of contact lists and databases.");
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, Default::default()))];
let l = lay(&d);
let lines: Vec<(f32, usize)> =
l.pages[0].items.iter().filter_map(|it| if let Placed::Lines { x, l0, l1, .. } = it { Some((*x, l1 - l0)) } else { None }).collect();
let (wide_x, wide_lines) = lines.last().copied().unwrap();
assert!(wide_x > 72.0 + 67.0, "the text is in the second column: {lines:?}");
assert!(wide_lines <= 3, "broken for the 348 pt column, not the 67 pt one: {lines:?}");
}
/// Space before at the top of a page, measured in Word for Windows (32 cases): a paragraph that
/// ends with a page break keeps its mark with the break (nothing of it goes to the new page) and the
/// next paragraph gets no space before there; a paragraph that starts with a page break starts on
/// the new page with its space before; a page break before keeps the space before in compatibility
/// modes up to 14 (less the previous paragraph's space after) and drops it in mode 15.
#[test]
fn space_before_at_the_top_of_a_page_like_word() {
let para = |text: &str, before: f32, pbb: bool| {
let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default());
p.props = ParaProps { space_before: Some(before), space_after: Some(0.0), page_break_before: Some(pbb), ..Default::default() };
wordcraft_doc::para_block(p)
};
let probe_top = |blocks: Vec<std::sync::Arc<wordcraft_doc::Block>>, mode: u32| {
let mut d = Document::new();
d.settings.compat_mode = mode;
let n = blocks.len();
d.body = blocks;
let l = lay(&d);
let c = l.caret(&Pos::body(n - 1, 0)).unwrap();
(c.page, c.top)
};
let plain_top = probe_top(vec![para("first", 0.0, false), para("PROBE", 0.0, true)], 15).1;
let brk = wordcraft_doc::para::PAGE_BREAK;
// A paragraph ending with a page break, then one with 24 pt before: no space, in both modes.
for mode in [12, 15] {
let (page, top) = probe_top(vec![para(&format!("first{brk}"), 0.0, false), para("PROBE", 24.0, false)], mode);
assert_eq!(page, 1, "mode {mode}");
assert!((top - plain_top).abs() < 0.05, "mode {mode}: the mark stays with the break and the space goes: {top} vs {plain_top}");
}
// A paragraph starting with a page break: its space before on the new page.
let mut d = Document::new();
d.body = vec![para("first", 0.0, false), para(&format!("{brk}PROBE"), 24.0, false)];
let l = lay(&d);
let c = l.caret(&Pos::body(1, brk.len_utf8())).unwrap();
assert_eq!(c.page, 1);
assert!((c.top - plain_top - 24.0).abs() < 0.05, "{} vs {}", c.top, plain_top + 24.0);
// Page break before: kept up to mode 14, dropped in 15.
let (_, kept) = probe_top(vec![para("first", 0.0, false), para("PROBE", 24.0, true)], 14);
let (_, dropped) = probe_top(vec![para("first", 0.0, false), para("PROBE", 24.0, true)], 15);
assert!((kept - plain_top - 24.0).abs() < 0.05 && (dropped - plain_top).abs() < 0.05, "{kept} {dropped} {plain_top}");
// After a page break before (mode 14) the previous paragraph's space after comes off: 24 - 10.
let mut first = wordcraft_doc::Paragraph::with_text("first", Default::default());
first.props = ParaProps { space_before: Some(0.0), space_after: Some(10.0), ..Default::default() };
let (_, top) = probe_top(vec![wordcraft_doc::para_block(first.clone()), para("PROBE", 24.0, true)], 14);
assert!((top - plain_top - 14.0).abs() < 0.05, "{top} vs {}", plain_top + 14.0);
// More after than before: nothing left.
first.props.space_after = Some(30.0);
let (_, top) = probe_top(vec![wordcraft_doc::para_block(first), para("PROBE", 24.0, true)], 14);
assert!((top - plain_top).abs() < 0.05, "{top} vs {plain_top}");
// The caret can still reach the end of a paragraph whose mark stayed with its page break.
let mut d = Document::new();
d.body = vec![para(&format!("first{brk}"), 0.0, false), para("next", 0.0, false)];
let l = lay(&d);
assert!(l.caret(&Pos::body(0, "first".len() + brk.len_utf8())).is_some(), "caret after the break");
}
/// Word kerns a run only when Kerning for fonts (`w:kern`) is set and the font is at least that
/// size; WordCraft kerned everything, so its lines held more text than Word's (measured in Word for
/// Windows with Calibri at 14 pt: no `w:kern`, `w:kern="0"` and a threshold above the size are
/// all unkerned).
#[test]
fn kerning_follows_kerning_for_fonts_like_word() {
use wordcraft_doc::props::CharProps;
let width = |kern: Option<f32>| {
let mut d = Document::new();
let chr = CharProps { size: Some(14.0), kern, ..Default::default() };
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("AVAVAV To Wo Ty Yo AWAY", chr))];
let l = lay(&d);
let end = d.para_at(&Pos::body(0, 0)).map_or(0, |p| p.len());
l.caret(&Pos::body(0, end)).unwrap().x - l.caret(&Pos::body(0, 0)).unwrap().x
};
let plain = width(Some(0.0));
assert!(width(Some(1.0)) < plain - 1.0, "kerned from 1 pt: {} vs {plain}", width(Some(1.0)));
assert!((width(Some(14.0)) - width(Some(1.0))).abs() < 0.01, "a threshold equal to the size kerns");
assert!((width(Some(15.0)) - plain).abs() < 0.01, "a threshold above the size doesn't kern");
// A new document kerns from 1 pt, like Word's Normal template.
assert!((width(None) - width(Some(1.0))).abs() < 0.01, "new documents kern");
let mut d = Document::new();
d.styles.default_chr.kern = None;
assert!(d.styles.resolve_char(None, &Default::default()).kern == 0.0, "no Kerning for fonts: not kerned");
}
/// Word's automatic (HTML-style) paragraph spacing, measured in Word for Windows: 14 pt at any
/// size; none before the first paragraph of the document or a cell, none after the last one of a
/// cell; next to another paragraph's space the larger of the two counts.
#[test]
fn automatic_paragraph_spacing_like_word() {
let auto = |before: bool, after: bool| ParaProps {
space_before: Some(5.0),
space_after: Some(5.0),
space_before_auto: before.then_some(true),
space_after_auto: after.then_some(true),
..Default::default()
};
let plain = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
let tops = |props: [ParaProps; 3]| {
let mut d = Document::new();
d.body = ["FIRST", "SECOND", "THIRD"]
.iter()
.zip(props)
.map(|(t, p)| {
let mut para = wordcraft_doc::Paragraph::with_text(t, Default::default());
para.props = p;
wordcraft_doc::para_block(para)
})
.collect();
let l = lay(&d);
[0, 1, 2].map(|i| l.caret(&Pos::body(i, 0)).unwrap().top)
};
let base = tops([plain.clone(), plain.clone(), plain.clone()]);
let line = base[1] - base[0];
let near = |a: f32, b: f32| (a - b).abs() < 0.05;
// Space after: 14 pt.
let t = tops([plain.clone(), auto(false, true), plain.clone()]);
assert!(near(t[2] - t[1], line + 14.0), "{t:?}");
// Space before: 14 pt, but none for the document's first paragraph.
let t = tops([auto(true, false), auto(true, false), plain.clone()]);
assert!(near(t[0], base[0]), "no automatic space at the top of the document");
assert!(near(t[1] - t[0], line + 14.0), "{t:?}");
// Two automatic spaces meet: 14 pt, not 28; an automatic one beside 10 pt: 14.
let t = tops([plain.clone(), auto(true, true), auto(true, true)]);
assert!(near(t[2] - t[1], line + 14.0), "{t:?}");
let ten = ParaProps { space_before: Some(10.0), ..plain.clone() };
let t = tops([plain.clone(), auto(false, true), ten]);
assert!(near(t[2] - t[1], line + 14.0), "{t:?}");
// Explicitly off: the measured spacing again.
let off = ParaProps { space_after_auto: Some(false), ..auto(false, false) };
let t = tops([plain.clone(), off, plain.clone()]);
assert!(near(t[2] - t[1], line + 5.0), "{t:?}");
}
/// In a table cell automatic spacing drops at the cell's top and bottom (Word for Windows).
#[test]
fn automatic_paragraph_spacing_drops_at_cell_edges() {
let cell_tops = |auto: bool| {
let mut d = Document::from_text("Above");
let mut t = Table::new(1, 1, 300.0);
t.rows[0].cells[0].blocks = ["SECOND", "THIRD"]
.iter()
.map(|s| {
let mut p = wordcraft_doc::Paragraph::with_text(s, Default::default());
p.props = ParaProps {
space_before: Some(0.0),
space_after: Some(0.0),
space_before_auto: auto.then_some(true),
space_after_auto: auto.then_some(true),
..Default::default()
};
wordcraft_doc::para_block(p)
})
.collect();
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
d.insert_block(StoryRef::Body, &Path::top(2), wordcraft_doc::Block::Para(wordcraft_doc::Paragraph::with_text("Below", Default::default())))
.unwrap();
let l = lay(&d);
let in_cell = |i: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, i]), off: 0 }).unwrap().top;
(in_cell(0), in_cell(1), l.caret(&Pos::body(2, 0)).unwrap().top)
};
let (a0, a1, a_below) = cell_tops(true);
let (p0, p1, p_below) = cell_tops(false);
assert!((a0 - p0).abs() < 0.05, "no automatic space at the top of the cell");
assert!((a1 - a0 - (p1 - p0) - 14.0).abs() < 0.05, "14 pt between the cell's paragraphs");
assert!((a_below - a1 - (p_below - p1)).abs() < 0.05, "none after the cell's last paragraph");
}
/// Automatic spacing inside a list, measured in Word for Windows (`plan/word` R17, 16 cases): none
/// between two list items, of one list or two, at any level, whichever item asks for it; the first
/// item keeps its automatic space before and the last its space after, a paragraph with its list
/// switched off in between keeps both, and measured spacing stays. NIH's bulleted IND reports
/// (each item automatic before and after) were 14 pt apart instead of touching.
#[test]
fn automatic_spacing_drops_between_list_items() {
use wordcraft_doc::props::NumRef;
let auto = ParaProps {
space_before: Some(5.0),
space_after: Some(5.0),
space_before_auto: Some(true),
space_after_auto: Some(true),
..Default::default()
};
let plain = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
let tops = |items: [ParaProps; 3], in_cell: bool| {
let mut d = Document::new();
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
let other = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
let fix = |p: ParaProps| ParaProps {
numbering: p.numbering.map(|n| NumRef {
num: match n.num {
0 => 0,
1 => num,
_ => other,
},
..n
}),
..p
};
let paras: Vec<_> = std::iter::once(plain.clone())
.chain(items.map(fix))
.chain(std::iter::once(plain.clone()))
.enumerate()
.map(|(i, p)| {
let mut para = wordcraft_doc::Paragraph::with_text(&format!("P{i}"), Default::default());
para.props = p;
wordcraft_doc::para_block(para)
})
.collect();
if in_cell {
let mut t = Table::new(1, 1, 300.0);
t.rows[0].cells[0].blocks = paras;
d.body = vec![wordcraft_doc::Block::Table(t).into()];
} else {
d.body = paras;
}
let at = |i: u32| if in_cell { Pos { story: StoryRef::Body, path: Path(vec![0, 0, 0, i]), off: 0 } } else { Pos::body(i as usize, 0) };
let l = lay(&d);
[0, 1, 2, 3, 4].map(|i| l.caret(&at(i)).unwrap().top)
};
let item = |num: u32, level: u8, p: &ParaProps| ParaProps { numbering: Some(NumRef { num, level }), ..p.clone() };
for in_cell in [false, true] {
let base = tops([plain.clone(), plain.clone(), plain.clone()], in_cell);
let line = base[1] - base[0];
let gaps = |t: [f32; 5]| [1, 2, 3, 4].map(|i| ((t[i] - t[i - 1] - line) * 10.0).round() / 10.0);
let one = item(1, 0, &auto);
let cases = [
("one list", [one.clone(), one.clone(), one.clone()], [14.0, 0.0, 0.0, 14.0]),
("two lists", [one.clone(), item(2, 0, &auto), item(2, 0, &auto)], [14.0, 0.0, 0.0, 14.0]),
("two levels", [one.clone(), item(1, 1, &auto), one.clone()], [14.0, 0.0, 0.0, 14.0]),
("a plain item between", [one.clone(), item(1, 0, &plain), one.clone()], [14.0, 0.0, 0.0, 14.0]),
("list off between", [one.clone(), item(0, 0, &auto), one.clone()], [14.0, 14.0, 14.0, 14.0]),
("no list", [auto.clone(), auto.clone(), auto.clone()], [14.0, 14.0, 14.0, 14.0]),
];
for (what, items, want) in cases {
assert_eq!(gaps(tops(items, in_cell)), want, "{what} (in a cell: {in_cell})");
}
let fourteen = ParaProps { space_before: Some(14.0), ..plain.clone() };
assert_eq!(
gaps(tops([item(1, 0, &fourteen), item(1, 0, &fourteen), item(1, 0, &fourteen)], in_cell)),
[14.0, 14.0, 14.0, 0.0],
"measured spacing stays"
);
}
}