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:
parent
8622ae8cec
commit
23395e7fc0
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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()))];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -499,12 +499,14 @@ fn layout_box(
|
|||
table: Option<¶::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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<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:?}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue