Tables: autofit by content, and floating tables inside cells (#291)

Word sizes the columns of a table that may follow its content (tblLayout
not fixed) from what is in them; we kept the stored grid as it was:

- A table with no preferred width anywhere (no tblW, no tcW: what
  generators write) now gets its columns from their content: each its
  widest line if all fit, else shared out between longest word and
  widest line, as Word does (case51: a nested table no longer fills its
  cell, and the document is two pages like Word's).
- In other autofit tables no column is narrower than its longest word;
  the columns with room give up the difference ("ON" no longer breaks
  into "O/N" in case91).

Floating tables (w:tblpPr) inside a table cell were laid out in the
text flow. They now stand at their position in the cell and take no
room; the text after them wraps around them, a table that may not
overlap goes below the ones in its way, and the cell grows to hold
them. A row whose lines all fit but whose nested floating table runs
past the page bottom splits there instead of moving to the next page.

Text now flows beside a floating object into gaps of 20pt or more
(was 36pt): Word puts a paragraph beside a floating table in a 21.3pt
gap but not in an 18.6pt one (docxide-pdf case139-148 vary just that).

docxide-pdf bench (365 documents, vs main + #135): Jaccard passes
208 -> 227, SSIM passes 76 -> 86, page counts right 320 -> 324; three
documents slightly worse (case149 -4 SSIM: Word moves the whole floating
table to the next page, we split it; two others -1 or less).

Co-authored-by: Oliver Zieschang <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
naxvog 2026-10-10 20:35:00 +02:00 committed by GitHub
parent 8622ae8cec
commit 23395e7fc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 535 additions and 77 deletions

View File

@ -801,6 +801,9 @@ pub enum VMerge {
pub struct CellProps {
/// Preferred width, points.
pub width: Option<f32>,
/// 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<f32>,
/// Number of grid columns spanned (1 = no horizontal merge).
pub span: u32,
pub vmerge: VMerge,

View File

@ -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 },

View File

@ -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));

View File

@ -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()))];
}

View File

@ -499,12 +499,14 @@ fn layout_box(
table: Option<&para::CellText>,
depth: usize,
frame: Option<PageFrame>,
) -> (Vec<Placed>, 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<Rect> = 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<Placed>,
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<PageFrame>,
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::<f32>().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<u32>) -> 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);
}

View File

@ -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

View File

@ -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<f32> = 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<Placed>,
@ -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<Rect>, Option<Rect>) {
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<Vec<(f32, f32)>> {
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<wordcraft_doc::numbering::Level> {
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 };
}
}

View File

@ -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, _)]) =
(&para_at(&l, &[0, 0, 0, 0, 0, 0, 0])[..], &para_at(&l, &[0, 0, 0, 0, 2, 0, 0])[..], &para_at(&l, &[0, 0, 0, 1])[..], &para_at(&l, &[1])[..])
else {
panic!("lines missing")
};
// The paragraph after the floating table runs beside it, level with its top.
assert!((by - iy).abs() < 2.0, "beside at {by}, inner table at {iy}");
assert!(bx > ix + 200.0, "beside text at {bx}, inner table text at {ix}");
// The cell grows to hold the whole floating table.
assert!(below_y > last_y + 10.0, "text below the outer table at {below_y}, the inner table's last row at {last_y}");
}
#[test]
fn autofit_sizes_columns_by_their_content() {
let text = |s: &str| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default()));
// No width anywhere: the columns come from their content.
let mut t = Table::new(1, 2, 468.0);
t.rows[0].cells[0].blocks = vec![text("Short")];
t.rows[0].cells[1].blocks = vec![text(&"A long cell that needs most of the width. ".repeat(4))];
t.rows[0].cells.iter_mut().for_each(|c| c.props.width = None);
let mut d = Document::from_text("After");
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let (&[(sx, _, _)], &[(lx, _, _)]) = (&para_at(&l, &[0, 0, 0, 0])[..], &para_at(&l, &[0, 0, 1, 0])[..]) else { panic!("lines missing") };
assert!(lx - sx < 80.0, "the short column is {}pt wide", lx - sx);
// A grid column narrower than its longest word grows; the others give up the difference.
let mut t = Table::new(1, 3, 468.0);
t.grid = vec![400.0, 20.0, 48.0];
t.rows[0].cells[1].blocks = vec![text("Unbreakable")];
let mut d = Document::from_text("After");
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let [(_, _, n)] = para_at(&l, &[0, 0, 1, 0])[..] else { panic!("line missing") };
assert_eq!(n, 1, "the word is broken over {n} lines");
}
#[test]
fn autofit_handles_spanning_cells_long_words_and_empty_tables() {
let text = |s: &str| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default()));
let table_x = |t: Table, path: &[u32]| {
let mut d = Document::from_text("After");
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
para_at(&lay(&d), path)
};
let automatic = |rows: usize, cols: usize| {
let mut t = Table::new(rows, cols, 468.0);
t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()).for_each(|c| c.props.width = None);
t
};
// A title cell spanning both columns needs its width shared between them: it stays on one line.
let mut t = automatic(2, 2);
t.rows[0].cells.truncate(1);
t.rows[0].cells[0].props.span = 2;
t.rows[0].cells[0].blocks = vec![text("A title that spans both columns")];
t.rows[1].cells[0].blocks = vec![text("A")];
t.rows[1].cells[1].blocks = vec![text("B")];
let [(_, _, n)] = table_x(t, &[0, 0, 0, 0])[..] else { panic!("title missing") };
assert_eq!(n, 1, "the title wraps over {n} lines");
// An unbreakable string longer than the page doesn't widen the table past it.
let mut t = automatic(1, 2);
t.rows[0].cells[0].blocks = vec![text(&"x".repeat(300))];
t.rows[0].cells[1].blocks = vec![text("Second")];
let [(x, _, _)] = table_x(t, &[0, 0, 1, 0])[..] else { panic!("second cell missing") };
assert!(x < 72.0 + 468.0, "the second column starts at {x}, past the margin");
// A table of empty cells keeps its grid.
let [(x, _, _)] = table_x(automatic(1, 2), &[0, 0, 1, 0])[..] else { panic!("second cell missing") };
assert!(x > 72.0 + 200.0, "the second empty column starts at {x}");
// Cells asking for a share of the table's width keep their grid too.
let mut t = automatic(1, 2);
for (c, s) in t.rows[0].cells.iter_mut().zip(["A", "B"]) {
c.props.width_pct = Some(50.0);
c.blocks = vec![text(s)];
}
let [(x, _, _)] = table_x(t, &[0, 0, 1, 0])[..] else { panic!("second cell missing") };
assert!(x > 72.0 + 200.0, "the second 50% column starts at {x}");
}
#[test]
fn a_floating_table_in_the_footer_leaves_the_footer_where_it_is() {
use wordcraft_doc::para::Anchor;
use wordcraft_doc::props::TableFloat;
let footer_y = |float: bool| {
let mut d = Document::from_text("Body");
let mut t = Table::new(1, 1, 100.0);
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Float", Default::default()))];
if float {
t.props.float = Some(TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Page, y: 720.0, ..Default::default() });
}
let blocks = vec![
wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Footer", Default::default())),
std::sync::Arc::new(wordcraft_doc::Block::Table(t)),
];
let id = d.add_part(wordcraft_doc::PartKind::Footer, blocks);
d.last_section.footers.default = Some(id);
let l = lay(&d);
l.pages[0].footer.iter().find_map(|it| if let Placed::Lines { y, path, .. } = it { (path.0 == [0]).then_some(*y) } else { None }).unwrap()
};
// A floating table in the footer doesn't push the footer's text up.
assert!(footer_y(true) >= footer_y(false) - 0.01, "footer text at {} with the float, {} without", footer_y(true), footer_y(false));
}
#[test]
fn a_split_row_moves_shading_below_the_cut_with_it() {
use crate::table::{RowLayout, split_row};
let line_row = |y: f32| {
let d = Document::from_text("Line");
let l = lay(&d);
let Some(Placed::Lines { story, path, para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })).cloned() else {
panic!("no line")
};
Placed::Lines { story, path, para, l0: 0, l1: 1, x: 0.0, y, turn: TextDirection::Horizontal }
};
let fill = |y: f32, h: f32| Placed::Fill { rect: wordcraft_geom::Rect::new(0.0, y, 100.0, h), color: wordcraft_doc::props::Rgb(200, 0, 0) };
// The row's own shading (from its top) and a nested cell's shading starting below the cut.
let row = RowLayout { height: 200.0, items: vec![line_row(0.0), line_row(150.0), fill(0.0, 200.0), fill(150.0, 50.0)] };
let (a, b) = split_row(&row, 100.0).expect("splits");
let fills = |r: &RowLayout| {
r.items.iter().filter_map(|i| if let Placed::Fill { rect, .. } = i { Some((rect.y, rect.h)) } else { None }).collect::<Vec<_>>()
};
assert_eq!(fills(&a), vec![(0.0, 100.0)], "only the row's shading above the cut stays");
let moved = fills(&b);
assert_eq!(moved.first().map(|f| f.0), Some(0.0), "the row's shading starts the continuation: {moved:?}");
let nested = moved.get(1).copied().unwrap_or_default();
assert!(nested.0 > 0.0 && (nested.1 - 50.0).abs() < 0.01, "the nested shading keeps its place and size: {moved:?}");
}