Files
wordcraft/crates/layout/src/tests.rs
T
da37b7987e Character border (Home > Font > Character Border) (#60)
* Character border (format.border): w:bdr run property, toggle command, boxed rendering

* Character border: Home > Font ribbon button and icon

* Character border: address review (plain notes), translate ribbon label, fix flaky layout test and clippy nonminimal_bool

* Tests: merge the fixture imports after merging main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: Spanish and Brazilian Portuguese labels for Character Border

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: WordCraft agent <agent@localhost>
Co-authored-by: 999sian <999sian@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:18:03 -04:00

870 lines
40 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};
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 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 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, 0), &|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 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());
}
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 }, &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 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: '→', .. })));
}
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 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, show_hidden: false, proofing: false });
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 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,
};
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 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),
};
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 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);
}
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 }
}
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).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).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() }).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 space before counts once: the picture and the text both start below it.
assert!((r.y - top).abs() < 0.5, "picture at {} but its paragraph's text starts at {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");
}
/// 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);
}
#[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");
}