Layout: keep a cell's floats laid out in the cell inside it (layoutInCell) (#4545)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ro-legal-docx
2026-11-05 21:56:00 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 334494a39f
commit 2f49c8ad2e
7 changed files with 310 additions and 9 deletions
+1
View File
@@ -775,6 +775,7 @@ MATH glyphs that reference the same construction now share its immutable variant
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. |
| 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` is preserved with true, false and absent values; OOXML properties follow schema order. Grid layout remains open. |
| 2026-10-28 | trivial | Per-page ink object lookups, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change |
| 2026-10-28 | trivial | Shared MATH construction records reuse variants and assembly data without changing glyph mappings or existing limits (#3630); readiness estimates unchanged |
+35 -6
View File
@@ -13,7 +13,14 @@ use super::{
/// Cell identity is independent of optional table-style formatting.
pub(crate) enum BoxContext<'a> {
Other,
Cell(Option<&'a para::CellText>),
/// Outer cell edges relative to its text origin (not including wrap distances).
Cell {
text: Option<&'a para::CellText>,
left: f32,
right: f32,
/// False for a turned text frame: its x axis becomes page y after rotation.
clamp: bool,
},
}
pub(crate) struct BoxInput<'a> {
@@ -32,7 +39,7 @@ impl<'a> BoxInput<'a> {
}
pub(crate) fn cell(story: StoryRef, blocks: &'a Blocks, prefix: &'a [u32], width: f32, cell: Option<&'a para::CellText>, depth: usize) -> Self {
Self { story, blocks, prefix, width, context: BoxContext::Cell(cell), depth, frame: None }
Self { story, blocks, prefix, width, context: BoxContext::Cell { text: cell, left: 0.0, right: width, clamp: false }, depth, frame: None }
}
}
@@ -191,9 +198,9 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
// inline (restored for the story around this box when it's done).
let outer_hide = std::mem::replace(&mut ctx.hide_deleted, ctx.opts.hides_deleted(story));
let empty_cell = para::CellText::default();
let (table, in_cell) = match context {
BoxContext::Other => (None, false),
BoxContext::Cell(table) => (Some(table.unwrap_or(&empty_cell)), true),
let (in_cell, table, cell_edges) = match context {
BoxContext::Other => (false, None, None),
BoxContext::Cell { text, left, right, clamp } => (true, Some(text.unwrap_or(&empty_cell)), clamp.then_some((left, right))),
};
// Word 16.113.3: the override applies in modes 11/12/14, but not mode 15.
let allow_same_style_space =
@@ -290,7 +297,16 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
// Only paragraph-relative placement excludes space-before. Line anchors
// and frameless page/margin fallbacks retain the text-top origin.
let float_y = if float.v_rel == Anchor::Paragraph { para_top } else { y };
let r = float_rect(frame, (0.0, width), float_y, w, h, float);
let mut r = float_rect(frame, (0.0, width), float_y, w, h, float);
// Only move an offset-positioned float when its box crosses the outer
// cell edge. Cell text margins and wrap distances do not move the object.
if let Some((left, right)) = cell_edges
&& !float.outside_cell
&& float.h_align.is_none()
&& float.wrap != Wrap::InFrontOfText
{
r.x = keep_in_cell(r.x, r.w, left, right);
}
let outline = ctx.wrap_outline(o);
excl.extend(wrap_area(r, float, outline.as_deref().map(Vec::as_slice)));
floats.insert(oi, r);
@@ -507,6 +523,19 @@ pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, te
r
}
/// Keep a float's box within the outer cell edges, relative to the cell's text origin.
/// Offsets that fit are unchanged, including those inside the text margins. Wrap distances
/// affect surrounding text only. An oversized object starts at the outer left edge.
pub(crate) fn keep_in_cell(x: f32, w: f32, left: f32, right: f32) -> f32 {
let left = if left.is_finite() { left } else { 0.0 };
let end = right - w;
if !end.is_finite() || end <= left {
return left;
}
// max/min also place a non-finite offset at a finite edge without panicking.
x.max(left).min(end)
}
/// A text box's default internal margins (Word's: 0.1" left/right, 0.05" top/bottom); a box's
/// own come from its [`wordcraft_doc::TextBody`].
#[cfg(test)]
+8 -2
View File
@@ -8,6 +8,7 @@ use wordcraft_doc::styles::{PartSet, TablePart, TableStyleProps};
use wordcraft_doc::{Block, Blocks, Paragraph, Row, StoryRef, Table};
use wordcraft_geom::Rect;
use crate::boxes::BoxContext;
use crate::para::{CellText, ClKind, ParaLayout};
use crate::{BoxInput, BoxLayout, Ctx, Placed, layout_box};
@@ -621,8 +622,13 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
turned = Some(Turned { turn, len, across, snap, region, ci, top: margins[0] + band_t });
(items, len)
} else {
let BoxLayout { mut items, mut height, floats_bottom, last_top, .. } =
layout_box(ctx, BoxInput::cell(story, &cell.blocks, &cpath, cw, text, depth));
let BoxLayout { mut items, mut height, floats_bottom, last_top, .. } = layout_box(
ctx,
BoxInput {
context: BoxContext::Cell { text, left: -margins[1], right: x1 - x0 - margins[1], clamp: true },
..BoxInput::cell(story, &cell.blocks, &cpath, cw, text, depth)
},
);
// A hidden end-of-cell mark: an empty last paragraph takes no room (Word for
// Windows: a row of empty such cells is only its borders).
if cell.props.hide_mark && cell.blocks.last().and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty()) {
+131
View File
@@ -7,6 +7,7 @@ use wordcraft_doc::{Pos, Table};
mod autospace;
mod bidi;
mod budget;
mod cell_floats;
mod complex;
mod grid;
mod ligature;
@@ -5843,6 +5844,110 @@ fn floats_in_headers_and_table_cells_are_drawn() {
assert!(c[0].x > 72.0 && c[0].x < 72.0 + 234.0, "inside the first cell: {:?}", c[0]);
}
/// A float laid out in its cell (`layoutInCell`) whose offset would take it out of the cell is
/// moved in to the cell's outer side (a letterhead logo anchored left of its header cell). One laid out on the page (`layoutInCell="0"`) keeps its offset.
#[test]
fn cell_floats_laid_out_in_the_cell_stay_in_it() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let cell_w = 234.0;
let place = |x: f32, outside_cell: bool, wrap: Wrap| {
let mut d = Document::from_text("Body text.");
let mut t = Table::new(1, 2, 468.0);
let mut cp = wordcraft_doc::Paragraph::with_text("cell", Default::default());
let f = Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, dist: 9.0, outside_cell, ..Default::default() };
cp.insert_object(0, rect_shape(80.0, 48.0, f), &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cp)];
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let c = shapes(&lay(&d).pages[0].items);
assert_eq!(c.len(), 1, "cell float drawn");
c[0].x
};
// An offset inside the cell is kept: the cell's text starts 20 pt left of this one.
let text_left = place(20.0, false, Wrap::Tight) - 20.0;
assert!(text_left > 72.0 && text_left < 72.0 + 12.0, "the cell's text starts past its margin: {text_left}");
let cell_left = text_left - 5.4;
// Anchored 95 pt left of the column: moved in to the cell's outer left side.
let left = place(-95.0, false, Wrap::Tight);
assert!((left - cell_left).abs() < 0.5, "pulled in to the left side: {left} vs {text_left}");
// Past the right side: moved in to it.
let right = place(cell_w + 40.0, false, Wrap::Tight);
// (within a point: the cell's border room is not split evenly between its two sides)
assert!((right - (cell_left + cell_w - 80.0)).abs() < 1.0, "pulled in to the right side: {right}");
// Not laid out in the cell: the offset is kept, off the cell or not.
let out = place(-95.0, true, Wrap::Tight);
assert!((out - (text_left - 95.0)).abs() < 0.5, "layoutInCell=0 keeps the offset: {out}");
// Front-of-text shapes do not wrap and keep their offsets even with layoutInCell on.
for offset in [-95.0, cell_w + 40.0] {
let front = place(offset, false, Wrap::InFrontOfText);
assert!((front - (text_left + offset)).abs() < 0.5, "front-of-text offset preserved: {front}");
}
}
/// Cell confinement uses the finite, nonnegative width that float placement actually draws.
#[test]
fn cell_float_bounds_use_the_placed_width() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let place = |width, x, wrap, outside_cell| {
let mut d = Document::from_text("Body");
let mut t = Table::new(1, 2, 468.0);
let mut p = wordcraft_doc::Paragraph::with_text("cell", Default::default());
let f = Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, dist: 9.0, outside_cell, ..Default::default() };
p.insert_object(0, rect_shape(width, 48.0, f), &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let objects = shapes(&lay(&d).pages[0].items);
assert_eq!(objects.len(), 1);
objects[0]
};
// Invalid stored widths are placed as zero-width objects; offsets at either side must
// match an explicitly zero-width object, rather than inventing extra room in the cell.
for offset in [-95.0, 300.0] {
let zero = place(0.0, offset, Wrap::Tight, false);
for stored in [-100.0, f32::NEG_INFINITY, f32::NAN, f32::INFINITY] {
let placed = place(stored, offset, Wrap::Tight, false);
assert_eq!(placed.w, 0.0);
assert!((placed.x - zero.x).abs() < 0.01, "stored width {stored}, offset {offset}: {placed:?} vs {zero:?}");
}
// Neither opt-out path gains confinement from the width normalization.
for (wrap, outside_cell) in [(Wrap::InFrontOfText, false), (Wrap::Tight, true)] {
let zero = place(0.0, offset, wrap, outside_cell);
let negative = place(-100.0, offset, wrap, outside_cell);
assert_eq!(negative.w, zero.w);
assert!((negative.x - zero.x).abs() < 0.01);
}
}
}
#[test]
fn keep_in_cell_bounds() {
use super::boxes::keep_in_cell;
assert_eq!(keep_in_cell(-95.0, 80.0, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(300.0, 80.0, 0.0, 234.0), 234.0 - 80.0);
assert_eq!(keep_in_cell(20.0, 80.0, 0.0, 234.0), 20.0);
// Wider than the cell, or exactly its width: starts at the outer left side.
assert_eq!(keep_in_cell(50.0, 300.0, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(50.0, 234.0, 0.0, 234.0), 0.0);
// Nearly full: no clearance is added to the object's position.
assert_eq!(keep_in_cell(-95.0, 230.0, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(300.0, 230.0, 0.0, 234.0), 4.0);
// Cell edges include its margins; negative offsets can be wholly inside the cell.
assert_eq!(keep_in_cell(-5.0, 80.0, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(-5.0, 80.0, -4.0, 234.0), -4.0);
assert_eq!(keep_in_cell(-5.15, 63.75, -5.4, 448.2), -5.15);
assert_eq!(keep_in_cell(395.95, 47.25, -5.4, 448.2), 395.95);
// Clamp limits stay finite and ordered even at small scales or with malformed geometry.
for width in [0.1_f32, 0.3, 1.0, 234.0] {
let object = width * 0.98;
for offset in [-width, 0.0, width, f32::NAN, f32::INFINITY] {
let x = keep_in_cell(offset, object, 0.0, width);
assert!(x.is_finite() && x >= 0.0 && x + object <= width + f32::EPSILON * width.max(1.0));
}
}
assert_eq!(keep_in_cell(10.0, 80.0, 0.0, f32::NAN), 0.0);
assert_eq!(keep_in_cell(10.0, f32::NAN, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(10.0, 80.0, f32::NAN, 234.0), 10.0);
}
/// Paragraph anchors exclude space-before; line and frameless page/margin anchors retain it.
#[test]
fn box_float_origins_respect_vertical_anchor_with_space_before() {
@@ -13435,3 +13540,29 @@ fn a_table_moved_by_a_page_break_shows_its_new_page_number() {
.collect();
assert_eq!(shown, ["2"]);
}
/// Cell confinement must not change page-vertical placement through the rotated text axis.
#[test]
fn turned_cell_float_keeps_its_along_text_offset() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let place = |direction, outside_cell| {
let mut doc = Document::from_text("Synthetic body");
let mut table = Table::new(1, 2, 468.0);
table.rows[0].props.height = Some(100.0);
table.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Exact;
table.rows[0].cells[0].props.text_direction = direction;
let mut para = Paragraph::with_text("Synthetic cell", Default::default());
let float = Float { wrap: Wrap::Tight, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 150.0, outside_cell, ..Default::default() };
para.insert_object(0, rect_shape(20.0, 10.0, float), &Default::default()).unwrap();
table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(para)];
doc.insert_block(StoryRef::Body, &Path::top(1), Block::Table(table)).unwrap();
let objects = shapes(&lay(&doc).pages[0].items);
assert_eq!(objects.len(), 1);
objects[0]
};
for direction in [TextDirection::Down, TextDirection::Up] {
let confined = place(direction, false);
let reference = place(direction, true);
assert!((confined.y - reference.y).abs() < 0.01, "{direction:?}: {confined:?} vs {reference:?}");
}
}
+130
View File
@@ -0,0 +1,130 @@
//! Synthetic letterhead geometry. No external documents or image assets are used.
use super::*;
use wordcraft_doc::para::{Anchor, Float, Wrap};
use wordcraft_doc::props::VAlign;
use wordcraft_doc::{Paragraph, PartKind};
fn picture(name: &str, w: f32, h: f32, x: f32, y: f32, wrap: Wrap, dist: f32) -> InlineObject {
InlineObject::Image {
media: name.into(),
w,
h,
alt: name.into(),
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, y, dist, ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
}
}
fn cell_rect(items: &[Placed]) -> Rect {
items.iter().find_map(|p| if let Placed::Cell { rect, .. } = p { Some(*rect) } else { None }).unwrap()
}
/// A body-table letterhead: a central front-of-text box and two pictures close to the cell's
/// outer edges. Both picture boxes fit; neither their offsets nor the box may move inward.
#[test]
fn cell_letterhead_keeps_picture_offsets_inside_the_outer_edges() {
let mut d = Document::from_text("Synthetic body");
d.last_section.page_w = 612.0;
d.last_section.page_h = 792.0;
d.last_section.margin_left = 70.9;
d.last_section.margin_right = 31.5;
d.last_section.margin_top = 31.2;
let mut t = Table::new(1, 1, 453.6);
t.props.style = None;
t.props.width = Some(453.6);
t.props.indent = Some(21.05);
let border = Some(Border { style: BorderStyle::Single, width: 0.5, ..Default::default() });
t.props.borders = Some(Borders { top: border, left: border, bottom: border, right: border, ..Default::default() });
t.rows[0].props.height = Some(63.75);
let mut p = Paragraph::default();
let box_story = d.add_part(PartKind::TextBox, vec![wordcraft_doc::para_block(Paragraph::with_text("Synthetic letterhead", Default::default()))]);
let mut central = rect_shape(332.25, 52.5, Float { wrap: Wrap::InFrontOfText, x: 59.9, y: 5.8, dist: 9.0, ..Default::default() });
if let InlineObject::Shape { story, .. } = &mut central {
*story = Some(box_story);
}
p.insert_object(0, central, &Default::default()).unwrap();
p.insert_object(0, picture("right", 47.25, 61.6, 395.95, 1.4, Wrap::Through, 9.0), &Default::default()).unwrap();
p.insert_object(0, picture("left", 63.75, 56.25, -5.15, 2.0, Wrap::Through, 9.0), &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
let layout = lay(&d);
let items = &layout.pages[0].items;
let cell = cell_rect(items);
let mut checked = 0;
for item in items {
if let Placed::Object { rect, origin, text_box, .. } = item {
let (x, y) = if text_box.is_some() {
(59.9, 5.8)
} else if rect.w > 60.0 {
(-5.15, 2.0)
} else {
(395.95, 1.4)
};
assert!(rect.x >= cell.x - 0.01 && rect.right() <= cell.right() + 0.01, "the original box fits the outer cell: {rect:?}, {cell:?}");
assert!((rect.x - origin.x - x).abs() < 0.5, "offset {x} was moved: {rect:?}, origin {origin:?}");
assert!((rect.y - origin.y - y).abs() < 0.01, "vertical placement is unchanged");
checked += 1;
}
}
assert_eq!(checked, 3, "two pictures and one central text box");
}
/// A centred header table wider than the text column, with a picture anchored far to its left.
/// The picture touches the outer cell edge, not the text inset or the wrap-distance boundary.
#[test]
fn cell_letterhead_pulls_an_outside_picture_to_the_outer_left_edge() {
for dist in [0.0, 9.0, 24.0] {
let mut d = Document::from_text("Synthetic body");
d.last_section.page_w = 612.0;
d.last_section.page_h = 792.0;
d.last_section.margin_left = 70.85;
d.last_section.margin_right = 70.85;
d.last_section.header = 5.65;
let mut t = Table::new(1, 1, 498.05);
t.props.style = None;
t.props.width_pct = Some(105.9);
t.props.align = Some(Align::Center);
t.rows[0].props.height = Some(67.95);
t.rows[0].cells[0].props.valign = Some(VAlign::Center);
let mut p = Paragraph::with_text("Synthetic header", Default::default());
p.insert_object(0, picture("logo", 79.0, 47.55, -94.7, 2.3, Wrap::BehindText, dist), &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
let header = d.add_part(PartKind::Header, vec![Arc::new(Block::Table(t))]);
d.last_section.headers.default = Some(header);
let layout = lay(&d);
let items = &layout.pages[0].header;
let cell = cell_rect(items);
assert!((cell.x - 56.976).abs() < 0.5, "the centred outer cell begins at {cell:?}");
let objects: Vec<_> =
items.iter().filter_map(|p| if let Placed::Object { rect, origin, .. } = p { Some((*rect, *origin)) } else { None }).collect();
assert_eq!(objects.len(), 1);
let (rect, origin) = objects[0];
assert!((rect.x - cell.x).abs() < 0.5, "wrap distance {dist}: picture {rect:?}, outer cell {cell:?}");
assert!((rect.y - origin.y - 2.3).abs() < 0.01, "vertical placement is unchanged");
}
}
/// Confinement follows the particular cell, including asymmetric margins mirrored for RTL.
#[test]
fn cell_float_outer_edges_include_asymmetric_and_mirrored_margins() {
for rtl in [false, true] {
for (offset, at_left) in [(-100.0, true), (300.0, false)] {
let mut d = Document::from_text("Body");
let mut t = Table::new(1, 2, 468.0);
t.props.bidi_visual = rtl;
t.props.cell_margins = Some([0.0, 12.0, 0.0, 3.0]);
let mut p = Paragraph::default();
p.insert_object(0, picture("logo", 40.0, 20.0, offset, 2.0, Wrap::BehindText, 9.0), &Default::default()).unwrap();
t.rows[0].cells[1].blocks = vec![wordcraft_doc::para_block(p)];
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
let layout = lay(&d);
let items = &layout.pages[0].items;
let cell = items.iter().find_map(|p| if let Placed::Cell { rect, cell: 1, .. } = p { Some(*rect) } else { None }).unwrap();
let picture = items.iter().find_map(|p| if let Placed::Image { rect, .. } = p { Some(*rect) } else { None }).unwrap();
let edge = if at_left { picture.x - cell.x } else { picture.right() - cell.right() };
assert!(edge.abs() < 0.5, "rtl {rtl}, offset {offset}: picture {picture:?}, cell {cell:?}");
}
}
}
+1
View File
@@ -165,6 +165,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. |
| 2026-11-05 | trivial | DOCX preserves `w:adjustRightInd` with its true default and writes it in schema order; grid layout remains open |
| 2026-10-28 | trivial | Ink page lookups reuse scanned paragraph prefixes, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change |
| 2026-10-28 | trivial | Shared MATH construction records reuse variants and assembly data without changing glyph mappings or existing limits (#3630); readiness estimates unchanged |
File diff suppressed because one or more lines are too long