diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index d759c11..2c8d913 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -801,6 +801,9 @@ pub enum VMerge { pub struct CellProps { /// Preferred width, points. pub width: Option, + /// Preferred width as a percentage of the table's (`w:tcW w:type="pct"`). + #[serde(skip_serializing_if = "Option::is_none")] + pub width_pct: Option, /// Number of grid columns spanned (1 = no horizontal merge). pub span: u32, pub vmerge: VMerge, diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 45f2d96..f58b1c5 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -375,11 +375,16 @@ pub fn tcpr(e: &El) -> (CellProps, bool) { let mut hcont = false; for k in e.els() { match k.name.as_str() { - "w:tcW" => { - if matches!(k.attr("w:type"), None | Some("dxa")) { - c.width = tw(k, "w:w").filter(|v| *v > 0.0); + "w:tcW" => match k.attr("w:type") { + None | Some("dxa") => c.width = tw(k, "w:w").filter(|v| *v > 0.0), + Some("pct") => { + let w = k.attr("w:w").unwrap_or("0"); + c.width_pct = if let Some(p) = w.strip_suffix('%') { measure(p, 1.0) } else { measure(w, 50.0) } + .map(|v| v.clamp(0.0, 100.0)) + .filter(|v| *v > 0.0); } - } + _ => {} + }, "w:gridSpan" => c.span = k.attr("w:val").and_then(u32_of).unwrap_or(1).clamp(1, 63), "w:hMerge" => hcont = k.attr("w:val") != Some("restart"), "w:vMerge" => c.vmerge = if k.attr("w:val") == Some("restart") { VMerge::Restart } else { VMerge::Continue }, diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 688d8df..16d7568 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -383,9 +383,10 @@ pub fn trpr(w: &mut W, r: &RowProps) { pub fn tcpr(w: &mut W, c: &CellProps) { w.open("w:tcPr", &[]); - match c.width { - Some(v) => w.empty("w:tcW", &[("w:w", &twips(v.max(0.0))), ("w:type", "dxa")]), - None => w.empty("w:tcW", &[("w:w", "0"), ("w:type", "auto")]), + match (c.width, c.width_pct) { + (Some(v), _) => w.empty("w:tcW", &[("w:w", &twips(v.max(0.0))), ("w:type", "dxa")]), + (None, Some(p)) => w.empty("w:tcW", &[("w:w", &n((p.clamp(0.0, 100.0) * 50.0).round() as i64)), ("w:type", "pct")]), + (None, None) => w.empty("w:tcW", &[("w:w", "0"), ("w:type", "auto")]), } if c.span > 1 { w.val("w:gridSpan", &n(c.span.min(63) as i64)); diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index e65f21b..5998869 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -235,6 +235,8 @@ fn tables_with_merges_round_trip() { c.props.text_direction = TextDirection::Up; c.props.no_wrap = true; c.props.margins = Some([2.0, 3.0, 4.0, 5.0]); + c.props.width = None; + c.props.width_pct = Some(40.0); c.props.borders = Some(Borders::box_(Border { style: BorderStyle::Dashed, width: 1.0, color: Some(Rgb(0, 0, 255)), space: 0.0 })); c.blocks = vec![para_block(Paragraph::with_text("red", CharProps::default()))]; } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 91624a0..a0faa61 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -499,12 +499,14 @@ fn layout_box( table: Option<¶::CellText>, depth: usize, frame: Option, -) -> (Vec, f32) { +) -> BoxLayout { let mut items = Vec::new(); // Pictures behind the text go first; `behind` counts them. let mut behind = 0; // Wrap areas of the box's floating objects. let mut excl: Vec<(Rect, bool)> = Vec::new(); + // Areas of the box's floating tables, which other floating tables may have to avoid. + let mut float_tables: Vec = Vec::new(); let mut y = 0.0f32; let mut prev_after = 0.0f32; let mut prev_style: Option<(String, bool)> = None; @@ -560,22 +562,103 @@ fn layout_box( continue; } let tl = table::layout_table(ctx, story, t, &path, width, depth + 1); - let mut ty = y; + let (tx, ty) = match t.props.float { + // A floating table stands at its own position and takes no room; the text + // after it wraps around it. + Some(f) => { + let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 }; + let (r, area) = box_float_table_rect(frame, width, y, &tl, &f, legacy, &float_tables); + excl.push((area, false)); + float_tables.push(area); + (r.x, r.y) + } + // A table in the text flow starts below the floating objects in its way. + None => (tl.x, below(&excl, y, tl.x, tl.x + tl.width)), + }; + let mut row_y = ty; for row in &tl.rows { for it in &row.items { let mut it = it.clone(); - it.translate(tl.x, ty); + it.translate(tx, row_y); items.push(it); } - ty += row.height; + row_y += row.height; + } + if t.props.float.is_none() { + y = row_y; + prev_after = 0.0; + prev_style = None; } - y = ty; - prev_after = 0.0; - prev_style = None; } } } - (items, y.max(0.0)) + let floats_bottom = float_tables.iter().map(|r| r.bottom()).fold(0.0, f32::max); + BoxLayout { items, height: y.max(0.0), floats_bottom } +} + +/// A laid-out box: items relative to its top-left, the height of its text flow, and where its +/// floating tables end (a table cell grows to hold them). +struct BoxLayout { + items: Vec, + height: f32, + floats_bottom: f32, +} + +/// Where floating table `tl` goes in a box `width` wide whose next paragraph starts at `y`, and +/// the area text keeps clear of. Positions relative to the page or its margins need the box's +/// `frame`; without one (a table cell) they're relative to the box. +fn box_float_table_rect( + frame: Option, + width: f32, + y: f32, + tl: &table::TableLayout, + f: &TableFloat, + legacy: f32, + float_tables: &[Rect], +) -> (Rect, Rect) { + let h = 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 page_x = |s: &SectionProps| match f.h_rel { + Anchor::Page => Some((0.0, s.page_w)), + Anchor::Margin => Some((s.margin_left + s.gutter, s.text_width())), + _ => None, + }; + let page_y = |s: &SectionProps| match f.v_rel { + Anchor::Page => Some((0.0, s.page_h)), + Anchor::Margin => Some((s.margin_top, s.text_height())), + _ => None, + }; + let h_area = frame.and_then(|fr| page_x(fr.sect).map(|(a, e)| (a - fr.origin.0, e))).unwrap_or((0.0, width)); + // In a cell an offset from the page or margin has nothing to measure from: the table stands + // where the text is. + let (v_area, y_off) = match frame.and_then(|fr| page_y(fr.sect).map(|(a, e)| (a - fr.origin.1, e))) { + Some(area) => (area, f.y), + None if matches!(f.v_rel, Anchor::Page | Anchor::Margin) => ((y, 0.0), 0.0), + None => ((y, 0.0), f.y), + }; + // Nor is there an area to align in: the table starts where the text is. + let v_align = if frame.is_none() && matches!(f.v_rel, Anchor::Page | Anchor::Margin) { None } else { f.v_align }; + let x = align_in(h_area, f.x, w, f.h_align) - if f.h_align.is_none() { legacy } else { 0.0 }; + let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy, h_area.0, float_tables); + let [dl, dt, dr, db] = f.dist_from_text(); + (r, r.inset(-dl, -dt, -dr, -db)) +} + +/// Floating table `f` at `r`, moved below the floating tables in its way when it may not +/// overlap them. In Word 2013+ layout one placed by an offset keeps its distance from the text's +/// left edge (`text_left`). +fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f32, others: &[Rect]) -> Rect { + let [dl, dt, dr, db] = f.dist_from_text(); + 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 Some(below) = others.iter().filter(|o| !f.overlap && o.intersects(&area)).map(|o| o.bottom()).reduce(f32::max) else { break }; + r.y = below + dt; + if keeps_text_distance { + r.x = r.x.max(text_left + dl); + } + } + r } /// A text box's internal margins (Word's defaults: 0.1" left/right, 0.05" top/bottom). @@ -962,7 +1045,7 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> for id in endnotes { let Some(part) = doc.parts.get(&id) else { continue }; let blocks = part.blocks.clone(); - let (items, h) = layout_box(&mut ctx, StoryRef::Part(id), &blocks, &[], pb.col_w(), None, 0, None); + let BoxLayout { items, height: h, .. } = layout_box(&mut ctx, StoryRef::Part(id), &blocks, &[], pb.col_w(), None, 0, None); if pb.y + h > pb.bottom && !pb.at_top() { pb.advance(block, pb.top); } @@ -1020,7 +1103,7 @@ fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option) -> f32 let blocks = part.blocks.clone(); // Placed as `headers_footers` draws it, so page-relative floats wrap the same way. let frame = PageFrame { sect, origin: (sect.margin_left + sect.gutter, sect.header) }; - let (_, h) = layout_box(ctx, StoryRef::Part(id), &blocks, &[], sect.text_width(), None, 0, Some(frame)); + let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], sect.text_width(), None, 0, Some(frame)).height; // Word starts the body right below a header that reaches past the top margin, no gap. sect.margin_top.max(sect.header + h) } @@ -1223,7 +1306,7 @@ fn place_objects( // Its text, unless the box budget says no (a box inside itself, too deep, too many). let Some(id) = text_box.filter(|id| ctx.boxes.enter(*id)) else { continue }; let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default(); - let (inner, _) = layout_box(ctx, StoryRef::Part(id), &blocks, &[], (rect.w - 2.0 * BOX_INSET_X).max(12.0), None, depth + 1, None); + let inner = layout_box(ctx, StoryRef::Part(id), &blocks, &[], (rect.w - 2.0 * BOX_INSET_X).max(12.0), None, depth + 1, None).items; ctx.boxes.leave(); let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front }; // Text that doesn't fit inside the margins is hidden, as in Word. @@ -1320,7 +1403,8 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, && let Some(part) = ctx.doc.parts.get(id).filter(|p| p.kind == wordcraft_doc::PartKind::Footnote) { let blocks = part.blocks.clone(); - let (items, h) = layout_box(ctx, StoryRef::Part(*id), &blocks, &[], pb.sect.text_width(), None, 0, None); + let BoxLayout { items, height: h, .. } = + layout_box(ctx, StoryRef::Part(*id), &blocks, &[], pb.sect.text_width(), None, 0, None); line_notes.push((*id, items, h)); } } @@ -1549,19 +1633,7 @@ fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &Table _ => (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); - } - } + let r = clear_of_float_tables(Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), f, legacy, h_area.0, &pb.float_tables); if attempt == 0 && r.bottom() > pb.bottom + 0.01 && !pb.at_top() && f.v_rel == Anchor::Paragraph { pb.advance(block, body_top); continue; @@ -1604,12 +1676,18 @@ fn float_table_x(pb: &PageBuilder, w: f32, f: &TableFloat, legacy: f32) -> f32 { /// 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) { + pb.y = below(&pb.excl, pb.y, x0, x1); +} + +/// `y`, moved below the wrap areas in `excl` in the way of something spanning `x0..x1` that +/// starts there. +fn below(excl: &[(Rect, bool)], mut y: f32, x0: f32, x1: f32) -> 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; + let in_the_way = excl.iter().filter(|(r, _)| r.y <= y + 0.01 && r.bottom() > y && r.x < x1 && r.right() > x0); + let Some(bottom) = in_the_way.map(|(r, _)| r.bottom()).reduce(f32::max) else { break }; + y = bottom; } + y } fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &SectionProps)]) { @@ -1665,7 +1743,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti { let blocks = part.blocks.clone(); let frame = PageFrame { sect, origin: (x, sect.header) }; - let (mut items, _) = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, Some(frame)); + let mut items = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, Some(frame)).items; for it in &mut items { it.translate(x, sect.header); } @@ -1677,9 +1755,9 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti { let blocks = part.blocks.clone(); // The footer's height fixes where it sits, which page-relative floats need. - let (_, h) = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, None); + let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, None).height; let frame = PageFrame { sect, origin: (x, sect.page_h - sect.footer - h) }; - let (mut items, _) = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, Some(frame)); + let mut items = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, Some(frame)).items; for it in &mut items { it.translate(x, sect.page_h - sect.footer - h); } diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 171b138..ef06280 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1502,8 +1502,9 @@ fn reorder_line(pl: &ParaLayout, line: &mut Line, width: f32) { line.xs = line.vis.iter().map(|v| v.x).chain(std::iter::once(end)).collect(); } -/// Narrowest span text flows into beside a floating object, points. -const MIN_SPAN: f32 = 36.0; +/// Narrowest span text flows into beside a floating object, points. Word puts an empty +/// paragraph beside a floating table in a 21.3pt gap but not in an 18.6pt one. +const MIN_SPAN: f32 = 20.0; /// The spans of a row at `top` (about `h` tall) between `lo` and `hi` that text can use around /// the exclusions, left to right (Square wrapping uses both sides of an object). `Err(y)`: none diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 6f5cc42..83c2cb9 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -5,11 +5,11 @@ use wordcraft_doc::numbering::Counters; use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge}; use wordcraft_doc::styles::TableStyleProps; -use wordcraft_doc::{StoryRef, Table}; +use wordcraft_doc::{Block, Blocks, Paragraph, StoryRef, Table}; use wordcraft_geom::Rect; -use crate::para::CellText; -use crate::{Ctx, Placed, layout_box}; +use crate::para::{CellText, ClKind, ParaLayout}; +use crate::{BoxLayout, Ctx, Placed, layout_box}; pub struct RowLayout { pub height: f32, @@ -97,9 +97,8 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 { pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout { let style = table_style(ctx, t); let parts = style.as_ref().map(|s| &s.parts); - // Cell text formatting per (header row, total row, first column, row band) region, built once - // per table. - let mut cell_text: Vec<((bool, bool, bool, bool), CellText)> = Vec::new(); + // Cell text formatting per region, built once per table. + let mut cell_text: Vec<(Region, CellText)> = Vec::new(); let ncols = t.cols().max(1); // Column widths. let mut grid: Vec = if t.grid.len() == ncols { @@ -123,6 +122,22 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava grid.iter_mut().for_each(|w| *w *= k); total = avail; } + if !t.props.fixed { + // Measuring lays paragraphs out once more; it mustn't count their equations twice. + let eq_count = ctx.eq_count; + let content = column_content(ctx, t, style.as_ref(), depth); + ctx.eq_count = eq_count; + if let Some(content) = content { + autofit(&mut grid, &content, t, avail); + // Never wider than the room it had, however long an unbreakable word. + let fitted: f32 = grid.iter().sum(); + let cap = avail.max(total); + if fitted > cap { + grid.iter_mut().for_each(|w| *w *= cap / fitted); + } + total = grid.iter().sum(); + } + } let mut colx = Vec::with_capacity(ncols + 1); let mut acc = 0.0; for w in &grid { @@ -153,7 +168,6 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava }; let nrows = t.rows.len(); let header_rows = t.props.look.header_row; - let band_size = parts.and_then(|p| p.band_size).unwrap_or(1).clamp(1, 1000) as usize; // First pass: lay out every cell's content. struct CellBox { items: Vec, @@ -180,7 +194,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let mut row_insets = 0.0f32; let is_header = header_rows && ri == 0; let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1; - let band = t.props.look.banded_rows && !is_header && (ri.saturating_sub(usize::from(header_rows)) / band_size).is_multiple_of(2); + let band = banded(t, style.as_ref(), ri); // The header row's or the odd band's own cell borders (conditional formatting). let region_borders = if is_header { parts.and_then(|p| p.header_borders) @@ -197,7 +211,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let margins = cell.props.margins.unwrap_or(margins_def); let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0); let mut fill = cell.props.shading; - let region = (is_header, is_total, t.props.look.first_column && g == 0, band); + let region = region_of(t, style.as_ref(), ri, g); if let Some(p) = parts { if is_header { fill = fill.or(p.header_fill); @@ -210,21 +224,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava if let Some(st) = &style && !cell_text.iter().any(|(r, _)| *r == region) { - // The whole table's formatting, then column, then row conditional formatting - // (later regions win, ECMA-376 §17.7.6). - let mut chr = st.chr.clone(); - if band { - chr.overlay(&st.parts.band_chr); - } - if region.2 { - chr.overlay(&st.parts.first_col_chr); - } - if is_header { - chr.overlay(&st.parts.header_chr); - } else if is_total { - chr.overlay(&st.parts.total_chr); - } - cell_text.push((region, CellText { para: st.para.clone(), chr })); + cell_text.push((region, region_text(st, region))); } let text = cell_text.iter().find(|(r, _)| *r == region).map(|(_, c)| c); let mut cpath = path.to_vec(); @@ -265,20 +265,22 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let len = match (row.props.height, row.props.height_rule) { (Some(h), HeightRule::Exact) if h > 0.0 => (h - insets).clamp(4.0, TURNED_LIMIT), _ => { - let (probe, _) = layout_box(ctx, story, &cell.blocks, &cpath, TURNED_MAX, text, depth, None); + let probe = layout_box(ctx, story, &cell.blocks, &cpath, TURNED_MAX, text, depth, None).items; (ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1); natural_length(&probe).clamp(4.0, TURNED_MAX) } }; - let (items, across) = layout_box(ctx, story, &cell.blocks, &cpath, len, text, depth, None); + let BoxLayout { items, height, floats_bottom } = layout_box(ctx, story, &cell.blocks, &cpath, len, text, depth, None); + let across = height.max(floats_bottom); turned = Some(Turned { turn, len, across, snap, region, ci, top: margins[0] + band_t }); (items, len) } else { - let (mut items, h) = layout_box(ctx, story, &cell.blocks, &cpath, cw, text, depth, None); + let BoxLayout { mut items, height, floats_bottom } = layout_box(ctx, story, &cell.blocks, &cpath, cw, text, depth, None); for it in &mut items { it.translate(x0 + margins[1], margins[0] + band_t); } - (items, h) + // The cell grows to hold its floating tables. + (items, height.max(floats_bottom)) }; let h = h + insets; if cell.props.vmerge != VMerge::Restart { @@ -385,7 +387,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let text = cell_text.iter().find(|(r, _)| *r == tn.region).map(|(_, c)| c); // Same list numbers as the first layout: lay it out from the same counters. let now = (std::mem::replace(&mut ctx.counters, tn.snap.0), std::mem::replace(&mut ctx.eq_count, tn.snap.1)); - (cell_items, across) = layout_box(ctx, story, &cell.blocks, &cpath, len, text, depth, None); + let b = layout_box(ctx, story, &cell.blocks, &cpath, len, text, depth, None); + (cell_items, across) = (b.items, b.height.max(b.floats_bottom)); (ctx.counters, ctx.eq_count) = now; } // Lines stack across the cell from its start edge (right for top-to-bottom text, @@ -475,7 +478,19 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> { _ => {} } } - if !kept_any || first_moved == f32::MAX { + if !kept_any { + return None; + } + // Every line fits and only a nested table's cell runs past the cut (a floating table's + // fixed-height row, say): split there rather than move the whole row on. A row that is just + // taller than its text moves on whole, as in Word. + let own_depth = row.items.iter().filter_map(|it| if let Placed::Cell { table, .. } = it { Some(table.0.len()) } else { None }).min(); + let nested_past_cut = + row.items.iter().any(|it| matches!(it, Placed::Cell { rect, table, .. } if Some(table.0.len()) > own_depth && rect.bottom() > cut + 0.01)); + if first_moved == f32::MAX && nested_past_cut { + first_moved = cut; + } + if first_moved == f32::MAX { return None; } // Keep a little of the cell's top margin on the continuation. @@ -529,15 +544,18 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> { b.push(it); } } + // Shading and cell areas: the part above the cut stays, the rest moves up with the + // continuation (a nested table's cell below the cut moves whole). Placed::Fill { rect, color } => { - a.push(Placed::Fill { rect: Rect::new(rect.x, rect.y, rect.w, (cut - rect.y).max(0.0)), color: *color }); - let h = (rect.bottom() - shift - 0.0).max(0.0); - b.push(Placed::Fill { rect: Rect::new(rect.x, 0.0, rect.w, h), color: *color }); + let (above, below) = split_rect(*rect, cut, shift); + a.extend(above.map(|rect| Placed::Fill { rect, color: *color })); + b.extend(below.map(|rect| Placed::Fill { rect, color: *color })); } Placed::Cell { rect, table, row: r, cell, story } => { - a.push(Placed::Cell { rect: Rect::new(rect.x, rect.y, rect.w, cut), table: table.clone(), row: *r, cell: *cell, story: *story }); - let h = (rect.bottom() - shift).max(0.0); - b.push(Placed::Cell { rect: Rect::new(rect.x, 0.0, rect.w, h), table: table.clone(), row: *r, cell: *cell, story: *story }); + let (above, below) = split_rect(*rect, cut, shift); + let cell_at = |rect| Placed::Cell { rect, table: table.clone(), row: *r, cell: *cell, story: *story }; + a.extend(above.map(cell_at)); + b.extend(below.map(cell_at)); } Placed::Rule { x0, y0, x1, y1, border } => { if (y0 - y1).abs() < 0.01 { @@ -567,3 +585,200 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> { } Some((RowLayout { height: cut, items: a }, RowLayout { height: (row.height - shift).max(4.0), items: b })) } + +/// The parts of `r` above `cut` and below it, the latter moved up by `shift` (onto the +/// continuation of a row split at `cut`). +fn split_rect(r: Rect, cut: f32, shift: f32) -> (Option, Option) { + let above = (r.y < cut).then(|| Rect::new(r.x, r.y, r.w, (r.bottom().min(cut) - r.y).max(0.0))); + let top = (r.y - shift).max(0.0); + let below = (r.bottom() > cut).then(|| Rect::new(r.x, top, r.w, (r.bottom() - shift - top).max(0.0))); + (above, below) +} + +/// A cell's region for table-style conditional formatting: (header row, total row, first column, +/// banded row). +type Region = (bool, bool, bool, bool); + +fn region_of(t: &Table, style: Option<&TableStyleProps>, ri: usize, g: usize) -> Region { + let (look, nrows) = (&t.props.look, t.rows.len()); + (look.header_row && ri == 0, look.total_row && ri + 1 == nrows && nrows > 1, look.first_column && g == 0, banded(t, style, ri)) +} + +/// Whether row `ri` is in an odd band of the table style's banded rows (bands of the style's +/// band size, counted below the header row). +fn banded(t: &Table, style: Option<&TableStyleProps>, ri: usize) -> bool { + let look = &t.props.look; + let size = style.and_then(|s| s.parts.band_size).unwrap_or(1).clamp(1, 1000) as usize; + look.banded_rows && !(look.header_row && ri == 0) && (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2) +} + +/// The table style's text formatting in `region`: the whole table's, then the band's, the +/// column's and the row's (later regions win, ECMA-376 §17.7.6). +fn region_text(st: &TableStyleProps, (header, total, first_col, band): Region) -> CellText { + let mut chr = st.chr.clone(); + if band { + chr.overlay(&st.parts.band_chr); + } + if first_col { + chr.overlay(&st.parts.first_col_chr); + } + if header { + chr.overlay(&st.parts.header_chr); + } else if total { + chr.overlay(&st.parts.total_chr); + } + CellText { para: st.para.clone(), chr } +} + +/// Each column's narrowest and widest content, cell margins included: its longest word and its +/// longest line. A cell spanning columns shares what they lack between them; nested tables count +/// with their own. `None` when every cell is empty. +fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, depth: usize) -> Option> { + let margins_def = default_margins(t, style); + let mut cols = vec![(0.0f32, 0.0f32); t.cols().max(1)]; + let mut spanning = Vec::new(); + let mut any = false; + for (ri, row) in t.rows.iter().enumerate() { + let mut g = 0usize; + for cell in &row.cells { + let span = cell.span(); + if cell.props.vmerge != VMerge::Continue { + let text = style.map(|st| region_text(st, region_of(t, style, ri, g))); + let (lo, hi) = blocks_content(ctx, &cell.blocks, text.as_ref(), depth); + any |= hi > 0.0; + let m = cell.props.margins.unwrap_or(margins_def); + let need = (lo + m[1] + m[3], hi + m[1] + m[3]); + match cols.get_mut(g) { + Some(col) if span == 1 => *col = (col.0.max(need.0), col.1.max(need.1)), + _ => spanning.push((g, span, need)), + } + } + g += span; + } + } + for (g, span, need) in spanning { + let Some(cs) = cols.get_mut(g..(g + span).min(t.cols())) else { continue }; + let n = cs.len().max(1) as f32; + let (have_lo, have_hi) = cs.iter().fold((0.0, 0.0), |(l, h), c| (l + c.0, h + c.1)); + for c in cs { + c.0 += (need.0 - have_lo).max(0.0) / n; + c.1 += (need.1 - have_hi).max(0.0) / n; + } + } + any.then_some(cols) +} + +/// The narrowest and widest a block list can be laid out. +fn blocks_content(ctx: &mut Ctx, blocks: &Blocks, text: Option<&CellText>, depth: usize) -> (f32, f32) { + let (mut lo, mut hi) = (0.0f32, 0.0f32); + for b in blocks.iter() { + let (l, h) = match &**b { + Block::Para(p) => { + let label = list_level(ctx, p).map(|l| (String::new(), l)); + para_content(&ctx.para_labelled(p, UNBOUNDED, text, &[], label)) + } + // A nested table: its fixed width, else its columns' but at least its preferred width + // (its own, else its grid's when it has preferred widths at all). + Block::Table(nt) if depth < 8 => { + let grid = if is_automatic(nt) { 0.0 } else { nt.grid.iter().filter(|w| w.is_finite()).map(|w| w.max(0.0)).sum() }; + let width = nt.props.width.filter(|w| w.is_finite()).unwrap_or(grid).clamp(0.0, UNBOUNDED); + if nt.props.fixed && width > 0.0 { + (width, width) + } else { + let style = table_style(ctx, nt); + let cols = column_content(ctx, nt, style.as_ref(), depth + 1).unwrap_or_default(); + let (l, h) = cols.iter().fold((0.0, 0.0), |(l, h), c| (l + c.0, h + c.1)); + (width.max(l), width.max(h)) + } + } + Block::Table(_) => (0.0, 0.0), + }; + lo = lo.max(l); + hi = hi.max(h); + } + (lo, hi) +} + +/// The list level `p` is in, for its indents (without counting it: measuring isn't laying out). +fn list_level(ctx: &Ctx, p: &Paragraph) -> Option { + let n = p.props.numbering.or_else(|| ctx.doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0)?; + ctx.doc.numbering.level(n.num, n.level).cloned() +} + +/// A width to lay a paragraph out in so that only its own line breaks end lines. +const UNBOUNDED: f32 = 100_000.0; + +/// A paragraph's longest word and longest line (between line breaks), indents included. +fn para_content(pl: &ParaLayout) -> (f32, f32) { + let indents = pl.rp.indent_left.max(0.0) + pl.rp.indent_right.max(0.0) + pl.rp.indent_first.max(0.0); + let (mut word, mut line, mut spaces) = (0.0f32, 0.0f32, 0.0f32); + let (mut lo, mut hi) = (0.0f32, 0.0f32); + for c in &pl.clusters { + let adv = if c.adv.is_finite() { c.adv.clamp(0.0, UNBOUNDED) } else { 0.0 }; + match c.kind { + ClKind::Space => { + lo = lo.max(word); + word = 0.0; + spaces += adv; + } + ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak => { + lo = lo.max(word); + hi = hi.max(line); + (word, line, spaces) = (0.0, 0.0, 0.0); + } + _ => { + word += adv; + line += spaces + adv; + spaces = 0.0; + if c.break_after { + lo = lo.max(word); + word = 0.0; + } + } + } + } + (lo.max(word) + indents, hi.max(line) + indents) +} + +/// Whether nothing in `t` asks for a width (its own or any cell's): its columns then follow +/// their content alone. +fn is_automatic(t: &Table) -> bool { + t.props.width.is_none() + && t.props.width_pct.is_none() + && t.rows.iter().all(|r| r.cells.iter().all(|c| c.props.width.is_none() && c.props.width_pct.is_none())) +} + +/// Word's autofit for a table whose widths may follow its content (`tblLayout` not fixed). +/// - With no preferred width anywhere (the table's and every cell's automatic) the columns come +/// from their content alone: each its widest line if they all fit in `avail`, else shared out +/// between narrowest and widest in proportion to how much each can give. +/// - Otherwise the grid stands, but no column is narrower than its longest word: the columns with +/// room to spare give up the difference. +fn autofit(grid: &mut [f32], content: &[(f32, f32)], t: &Table, avail: f32) { + if grid.len() != content.len() || !avail.is_finite() { + return; + } + if is_automatic(t) { + let (lo, hi) = content.iter().fold((0.0f32, 0.0f32), |(l, h), c| (l + c.0, h + c.1.max(c.0))); + let k = if hi <= avail { + 1.0 + } else if lo >= avail || hi - lo < 0.01 { + 0.0 + } else { + (avail - lo) / (hi - lo) + }; + for (w, c) in grid.iter_mut().zip(content) { + *w = (c.0 + (c.1.max(c.0) - c.0) * k).max(4.0); + } + return; + } + let short: f32 = grid.iter().zip(content).map(|(w, c)| (c.0 - *w).max(0.0)).sum(); + if short < 0.01 { + return; + } + let spare: f32 = grid.iter().zip(content).map(|(w, c)| (*w - c.0).max(0.0)).sum(); + let give = (short / spare.max(0.01)).min(1.0); + for (w, c) in grid.iter_mut().zip(content) { + *w = if *w < c.0 { c.0 } else { *w - (*w - c.0) * give }; + } +} diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index ad198c5..0d5872f 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2372,3 +2372,156 @@ fn hidden_float_in_a_text_box_is_not_placed() { 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}"); } + +/// 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, _)]) = + (¶_at(&l, &[0, 0, 0, 0, 0, 0, 0])[..], ¶_at(&l, &[0, 0, 0, 0, 2, 0, 0])[..], ¶_at(&l, &[0, 0, 0, 1])[..], ¶_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, _, _)]) = (¶_at(&l, &[0, 0, 0, 0])[..], ¶_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::>() + }; + 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:?}"); +}