diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 281b240dc..b09d3722b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -253,8 +253,14 @@ pub struct Settings { pub footnote_format: section::NumFormat, pub endnote_format: section::NumFormat, pub protection: Option, + /// Word compatibility mode the document is laid out in (`compatibilityMode`): 15 for Word + /// 2013 and later, which places a table's border at the margin rather than its text. + pub compat_mode: u32, } +/// Word's compatibility mode for documents that don't state one. +pub const LEGACY_COMPAT_MODE: u32 = 12; + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Watermark { @@ -304,6 +310,7 @@ impl Default for Settings { footnote_format: section::NumFormat::Decimal, endnote_format: section::NumFormat::LowerRoman, protection: None, + compat_mode: 15, } } } diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 892cfdf54..08dea06b7 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -622,6 +622,36 @@ pub struct TableProps { pub shading: Option, /// Alternative text. pub caption: Option, + /// Floating placement (`w:tblpPr`); `None` for a table in the text flow. + #[serde(skip_serializing_if = "Option::is_none")] + pub float: Option, +} + +/// Where a floating table sits; the text after it wraps around it. +#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TableFloat { + /// What `x` is measured from: the column (`Column`), the margin or the page. + pub h_rel: crate::para::Anchor, + /// What `y` is measured from: the text where the table stands (`Paragraph`), the margin or + /// the page. + pub v_rel: crate::para::Anchor, + /// Offsets, points (used when the matching alignment is `None`). + pub x: f32, + pub y: f32, + pub h_align: Option, + pub v_align: Option, + /// Distance from surrounding text: left, top, right, bottom (points). + pub dist: [f32; 4], + /// Whether it may overlap other floating tables (`w:tblOverlap`); Word's default is yes. + pub overlap: bool, +} + +impl TableFloat { + /// The distances from text, finite and clamped (left, top, right, bottom). + pub fn dist_from_text(&self) -> [f32; 4] { + self.dist.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }) + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index d49630f0f..e7e9d9e68 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -88,6 +88,8 @@ pub fn read(bytes: &[u8]) -> Result { if let Some(p) = part(rt::NUMBERING) { lenient("numbering", r.read_numbering(&p)); } + // Without a stated mode (or settings at all) Word lays a document out as Word 2007 did. + r.doc.settings.compat_mode = wordcraft_doc::LEGACY_COMPAT_MODE; if let Some(p) = part(rt::SETTINGS) { lenient("settings", r.read_settings(&p)); } @@ -547,6 +549,12 @@ impl Reader<'_> { let s = &mut self.doc.settings; for k in root.els() { match k.name.as_str() { + "w:compat" => { + let mode = k.children("w:compatSetting").find(|c| c.attr("w:name") == Some("compatibilityMode")).and_then(|c| c.attr("w:val")); + if let Some(m) = mode.and_then(u32_of) { + s.compat_mode = m.clamp(11, 99); + } + } "w:trackRevisions" => s.track_changes = on_off(k), "w:defaultTabStop" => { if let Some(v) = tw(k, "w:val").filter(|v| *v > 0.0) { diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 0ba0132a5..912a0dc0b 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use wordcraft_doc::props::{ Align, Border, BorderStyle, Borders, CellProps, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, - TabLeader, TabStop, TableLook, TableProps, TextColor, Underline, VAlign, VMerge, VertAlign, + TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, Underline, VAlign, VMerge, VertAlign, }; use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart}; @@ -277,6 +277,12 @@ impl PropCtx { "w:tblLayout" => t.fixed = k.attr("w:type") == Some("fixed"), "w:tblCellMar" => t.cell_margins = Some(margins(k)), "w:tblLook" => t.look = look(k), + "w:tblpPr" => t.float = Some(table_float(k)), + "w:tblOverlap" => { + if let Some(f) = t.float.as_mut() { + f.overlap = k.attr("w:val") != Some("never"); + } + } "w:tblCaption" => t.caption = k.attr("w:val").map(str::to_string), "w:tblDescription" if t.caption.is_none() => t.caption = k.attr("w:val").map(str::to_string), _ => {} @@ -482,3 +488,26 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec) { } (s, refs) } + +/// `w:tblpPr`: a floating table's anchors, offsets or alignments and distances from text. +fn table_float(e: &El) -> TableFloat { + use wordcraft_doc::para::{Anchor, FloatAlign}; + let pt = |n: &str| e.attr(n).and_then(|v| measure(v, 20.0)).map(|v| v.clamp(-31_680.0, 31_680.0)).unwrap_or(0.0); + let dist = |n: &str| pt(n).clamp(0.0, 1584.0); + let rel = |n: &str, text: Anchor| match e.attr(n) { + Some("page") => Anchor::Page, + Some("margin") => Anchor::Margin, + _ => text, + }; + let align = |n: &str| e.attr(n).and_then(FloatAlign::from_ooxml); + TableFloat { + h_rel: rel("w:horzAnchor", Anchor::Column), + v_rel: rel("w:vertAnchor", Anchor::Paragraph), + x: pt("w:tblpX"), + y: pt("w:tblpY"), + h_align: align("w:tblpXSpec"), + v_align: align("w:tblpYSpec"), + dist: [dist("w:leftFromText"), dist("w:topFromText"), dist("w:rightFromText"), dist("w:bottomFromText")], + overlap: true, + } +} diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 77b909645..db1e07b4d 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -756,7 +756,8 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec { w.close(tag); } w.open("w:compat", &[]); - w.empty("w:compatSetting", &[("w:name", "compatibilityMode"), ("w:uri", "http://schemas.microsoft.com/office/word"), ("w:val", "15")]); + let mode = doc.settings.compat_mode.clamp(11, 15).to_string(); + w.empty("w:compatSetting", &[("w:name", "compatibilityMode"), ("w:uri", "http://schemas.microsoft.com/office/word"), ("w:val", &mode)]); w.close("w:compat"); w.close("w:settings"); w.into_bytes() diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 69b59db51..93d0fdd5b 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -2,7 +2,7 @@ use wordcraft_doc::props::{ Align, Border, BorderStyle, Borders, CellProps, CharProps, HeightRule, Highlight, LineSpacing, ParaProps, RowProps, TabAlign, TabLeader, - TableLook, TableProps, TextColor, VAlign, VMerge, VertAlign, + TableFloat, TableLook, TableProps, TextColor, VAlign, VMerge, VertAlign, }; use crate::units::{n, twips}; @@ -305,6 +305,9 @@ pub fn tblpr(w: &mut W, t: &TableProps) { if let Some(s) = &t.style { w.val("w:tblStyle", s); } + if let Some(f) = &t.float { + table_float(w, f); + } if let Some(p) = t.width_pct { w.empty("w:tblW", &[("w:w", &n(round(p.clamp(0.0, 1000.0) * 50.0))), ("w:type", "pct")]); } else if let Some(v) = t.width { @@ -397,3 +400,36 @@ pub fn tcpr(w: &mut W, c: &CellProps) { } w.close("w:tcPr"); } + +/// `w:tblpPr` and `w:tblOverlap` for a floating table. +fn table_float(w: &mut W, f: &TableFloat) { + use wordcraft_doc::para::Anchor; + let rel = |a: Anchor, text: &'static str| match a { + Anchor::Page => "page", + Anchor::Margin => "margin", + _ => text, + }; + let [left, top, right, bottom] = f.dist_from_text().map(twips); + let fin = |v: f32| twips(if v.is_finite() { v.clamp(-31_680.0, 31_680.0) } else { 0.0 }); + let (x, y) = (fin(f.x), fin(f.y)); + let mut a: Vec<(&str, &str)> = vec![ + ("w:leftFromText", &left), + ("w:rightFromText", &right), + ("w:topFromText", &top), + ("w:bottomFromText", &bottom), + ("w:vertAnchor", rel(f.v_rel, "text")), + ("w:horzAnchor", rel(f.h_rel, "text")), + ]; + match f.h_align { + Some(h) => a.push(("w:tblpXSpec", h.ooxml(true))), + None => a.push(("w:tblpX", &x)), + } + match f.v_align { + Some(v) => a.push(("w:tblpYSpec", v.ooxml(false))), + None => a.push(("w:tblpY", &y)), + } + w.empty("w:tblpPr", &a); + if !f.overlap { + w.val("w:tblOverlap", "never"); + } +} diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index b33af0458..02cfa8865 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -473,6 +473,11 @@ fn anchor_alignment_reference_areas_and_distances() { assert_eq!((b.dist, b.dist_top, b.dist_bottom), (0.0, 0.0, 0.0)); } +#[test] +fn documents_without_a_compatibility_mode_are_laid_out_as_word_2007() { + assert_eq!(read_body("").settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE); +} + #[test] fn style_rfonts_without_ascii_inherits_doc_defaults_font() { // Issue #93: Normal names only East Asian / complex-script fonts, so Latin text keeps the diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 920ad0a16..139cc9d6f 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -889,6 +889,44 @@ fn ensure_empty_and_table_end_document_is_valid() { assert_eq!(r.body.len(), 1); } +#[test] +fn compatibility_mode_round_trips() { + let mut d = Document::new(); + assert_eq!(d.settings.compat_mode, 15, "new documents are Word 2013+ documents"); + let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(back.settings.compat_mode, 15); + d.settings.compat_mode = 14; + let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(back.settings.compat_mode, 14); +} + +#[test] +fn floating_tables_round_trip() { + use wordcraft_doc::para::{Anchor, FloatAlign}; + use wordcraft_doc::props::TableFloat; + let mut d = Document::new(); + let mut t = Table::new(1, 2, 200.0); + let f = TableFloat { + h_rel: Anchor::Margin, + v_rel: Anchor::Paragraph, + x: 0.0, + y: -1.65, + h_align: None, + v_align: None, + dist: [9.0, 0.0, 12.0, 3.0], + overlap: false, + }; + t.props.float = Some(f); + let mut page = Table::new(1, 1, 100.0); + let centred = + TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Margin, h_align: Some(FloatAlign::Center), y: 36.0, overlap: true, ..Default::default() }; + page.props.float = Some(centred); + d.body = vec![Arc::new(Block::Table(t)), Arc::new(Block::Table(page)), para_block(Paragraph::with_text("after", CharProps::default()))]; + let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + let floats: Vec<_> = back.body.iter().filter_map(|b| if let Block::Table(t) = &**b { t.props.float } else { None }).collect(); + assert_eq!(floats, [f, centred]); +} + #[test] fn list_level_overrides_round_trip() { use wordcraft_doc::numbering::{Counters, Level}; diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 4029843ab..7474a318e 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -20,7 +20,7 @@ use std::sync::Arc; use wordcraft_doc::numbering::{Counters, Level}; use wordcraft_doc::para::{Anchor, Float, FloatAlign, InlineObject, Wrap}; -use wordcraft_doc::props::{Border, CharProps, Rgb}; +use wordcraft_doc::props::{Border, CharProps, Rgb, TableFloat}; use wordcraft_doc::section::{SectionProps, SectionStart}; use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, StoryRef}; use wordcraft_geom::Rect; @@ -463,6 +463,8 @@ struct PageBuilder<'a> { prev: Option<(String, bool, f32)>, /// Wrap areas of floating objects on this page (see [`wrap_area`]). excl: Vec<(Rect, bool)>, + /// Wrap areas of the floating tables on this page, which others may not overlap. + float_tables: Vec, /// Line numbering counter. line_no: u32, /// Index of the first body item of the current page (vertical alignment shifts from here). @@ -525,6 +527,7 @@ impl PageBuilder<'_> { self.apply_valign(); self.flush_notes(); self.excl.clear(); + self.float_tables.clear(); if self.sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Page) { self.line_no = 0; } @@ -658,6 +661,7 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> orig_bottom: 0.0, prev: None, excl: Vec::new(), + float_tables: Vec::new(), line_no: 0, page_items_start: 0, }; @@ -1156,7 +1160,22 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) { pb.prev = None; let width = pb.col_w(); - let tl = table::layout_table(ctx, StoryRef::Body, t, &[block as u32], width, 1); + let mut tl = table::layout_table(ctx, StoryRef::Body, t, &[block as u32], width, 1); + if let Some(f) = t.props.float.filter(|_| !pb.web) { + // Before Word 2013 layout (compatibility mode 15) an offset places the first cell's text, + // so the edge sits a cell margin further out. + let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(t) } else { 0.0 }; + let h: f32 = tl.rows.iter().map(|r| r.height).sum(); + if h <= pb.bottom - pb.top + 0.01 { + place_floating_table(pb, &tl, &f, legacy, block, body_top); + return; + } + // Too tall for a page: Word lets it run across pages like a table in the text, from its + // own left edge. + tl.x = float_table_x(pb, tl.width, &f, legacy) - pb.col_x(); + } + // A table in the text flow starts below the floating objects in its way. + below_floats(pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width); let header_rows: Vec = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect(); let place = |pb: &mut PageBuilder, row: &table::RowLayout| { let (x, y) = (pb.col_x() + tl.x, pb.y); @@ -1201,6 +1220,85 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl } } +/// Place floating table `tl` (`w:tblpPr`): at its own position, which takes no room in the text +/// flow; the text after it wraps around it. +fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &TableFloat, legacy: f32, block: usize, body_top: f32) { + let h: f32 = tl.rows.iter().map(|r| r.height).sum::().clamp(0.0, 100_000.0); + let w = tl.width.clamp(1.0, 100_000.0); + let [dl, dt, dr, db] = f.dist_from_text(); + for attempt in 0..2 { + let s = pb.sect; + let h_area = float_table_h_area(pb, f); + // The text a table anchored to it stands in: where the next paragraph starts. + let v_area = match f.v_rel { + Anchor::Page => (0.0, s.page_h), + Anchor::Margin => (s.margin_top, s.text_height()), + _ => (pb.y, 0.0), + }; + let x = float_table_x(pb, w, f, legacy); + let mut r = Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h); + // Word moves a table that may not overlap below the floating tables in its way; in Word + // 2013+ layout one placed by an offset keeps its distance from the text's left edge. + let keeps_text_distance = legacy == 0.0 && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column); + for _ in 0..64 { + let area = r.inset(-dl, -dt, -dr, -db); + let in_the_way = pb.float_tables.iter().filter(|o| !f.overlap && o.intersects(&area)); + let Some(below) = in_the_way.map(|o| o.bottom()).reduce(f32::max) else { break }; + r.y = below + dt; + if keeps_text_distance { + r.x = r.x.max(h_area.0 + dl); + } + } + if attempt == 0 && r.bottom() > pb.bottom + 0.01 && !pb.at_top() && f.v_rel == Anchor::Paragraph { + pb.advance(block, body_top); + continue; + } + if let Some(pg) = pb.page() { + let mut y = r.y; + for row in &tl.rows { + for it in &row.items { + let mut it = it.clone(); + it.translate(r.x, y); + pg.items.push(it); + } + y += row.height; + } + } + let area = r.inset(-dl, -dt, -dr, -db); + pb.excl.push((area, false)); + pb.float_tables.push(area); + return; + } +} + +/// The area a floating table's `x` is measured in: its start and width on the page. +fn float_table_h_area(pb: &PageBuilder, f: &TableFloat) -> (f32, f32) { + let s = pb.sect; + match f.h_rel { + Anchor::Page => (0.0, s.page_w), + Anchor::Margin => (s.margin_left + s.gutter, s.text_width()), + _ => (pb.col_x(), pb.col_w()), + } +} + +/// A floating table's left edge on the page (`legacy`: the first cell's left margin before Word +/// 2013 layout, when an offset places the cell's text). +fn float_table_x(pb: &PageBuilder, w: f32, f: &TableFloat, legacy: f32) -> f32 { + let x = align_in(float_table_h_area(pb, f), f.x, w, f.h_align); + if f.h_align.is_none() { x - legacy } else { x } +} + +/// Move `pb.y` below the floating objects in the way of something spanning `x0..x1` that +/// starts there (a table in the text flow doesn't wrap around them). +fn below_floats(pb: &mut PageBuilder, x0: f32, x1: f32) { + for _ in 0..64 { + let y = pb.y; + let in_the_way = pb.excl.iter().filter(|(r, _)| r.y <= y + 0.01 && r.bottom() > y && r.x < x1 && r.right() > x0); + let Some(below) = in_the_way.map(|(r, _)| r.bottom()).reduce(f32::max) else { break }; + pb.y = below; + } +} + fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &SectionProps)]) { let total = pages.len() as u32; // Pages per section. diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 73ebc5719..003b03a13 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -18,13 +18,18 @@ pub struct RowLayout { pub struct TableLayout { /// Table x offset within the column. pub x: f32, - #[allow(dead_code)] pub width: f32, pub rows: Vec, } const DEFAULT_MARGINS: [f32; 4] = [0.0, 5.4, 0.0, 5.4]; +/// The first cell's left margin: how far its text sits inside the table's edge. +pub(crate) fn first_cell_left_margin(t: &Table) -> f32 { + let def = t.props.cell_margins.unwrap_or(DEFAULT_MARGINS); + t.rows.first().and_then(|r| r.cells.first()).and_then(|c| c.props.margins).unwrap_or(def)[1] +} + fn style_parts(ctx: &Ctx, t: &Table) -> Option { let id = t.props.style.as_deref()?; ctx.doc.styles.chain(id).iter().rev().find_map(|s| s.table.clone()) @@ -62,18 +67,27 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava acc += w; } colx.push(acc); - // Word places the table so cell text lines up with the margin: shift left by the left cell margin. + // Word 2013 and later (compatibility mode 15) put the table's border at the margin (plus its + // indent); earlier modes line the first cell's text up with it instead. let margins_def = t.props.cell_margins.unwrap_or(DEFAULT_MARGINS); let indent = t.props.indent.unwrap_or(0.0); - let x = match t.props.align { - Some(Align::Center) => (avail - total) / 2.0, - Some(Align::Right) => avail - total, - _ => indent - margins_def[1], - }; + // The style's borders, overlaid by the table's own side by side. let mut tborders = parts.as_ref().and_then(|p| p.borders).unwrap_or_default(); if let Some(own) = t.props.borders { tborders.overlay(&own); } + let first_cell = t.rows.first().and_then(|r| r.cells.first()); + let x = match t.props.align { + Some(Align::Center) => (avail - total) / 2.0, + Some(Align::Right) => avail - total, + _ if ctx.doc.settings.compat_mode >= 15 => { + // The border is centred on the edge, so half of it sits outside: Word moves the + // table in by that half. + let border = first_cell.and_then(|c| c.props.borders.and_then(|b| b.left)).or(tborders.left); + indent + border.filter(Border::is_visible).map_or(0.0, |b| b.width.clamp(0.0, 12.0) / 2.0) + } + _ => indent - first_cell_left_margin(t), + }; let nrows = t.rows.len(); let header_rows = t.props.look.header_row; // First pass: lay out every cell's content. diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index a2e46f264..f6c3d103b 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -710,6 +710,90 @@ fn list_items_beside_a_float_in_a_cell_are_counted_once() { 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.