diff --git a/ROADMAP.md b/ROADMAP.md index 50dc50bc72..4be68c7403 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/layout/src/boxes.rs b/crates/layout/src/boxes.rs index ed5f91cf30..13eacf1b5f 100644 --- a/crates/layout/src/boxes.rs +++ b/crates/layout/src/boxes.rs @@ -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)] diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 8539dd145b..4876e940c8 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -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()) { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index e3a67569ce..b56932b308 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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:?}"); + } +} diff --git a/crates/layout/src/tests/cell_floats.rs b/crates/layout/src/tests/cell_floats.rs new file mode 100644 index 0000000000..01c174200e --- /dev/null +++ b/crates/layout/src/tests/cell_floats.rs @@ -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:?}"); + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 642c65b9ec..a164c5ce7d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 2f15abd42e..498ca5811c 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (MATH construction reuse, #3630; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial (ODT documents: a line of only pictures keeps its font's height; no setting for it any more); previously trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** trivial (PR #4545: eligible cell floats keep in-bounds offsets; overflow confinement retains the front-of-text, turned-cell and native-acceptance limits); previously trivial (MATH construction reuse, #3630; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial (ODT documents: a line of only pictures keeps its font's height; no setting for it any more); previously trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -265,6 +265,8 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. MATH glyphs that reference the same construction now share its immutable variants and assembly data (#3630). Glyph mappings, values and the existing partial-record fallback stay unchanged. Repeated glyph IDs keep their last valid reference without retaining superseded constructions. Distinct overlapping constructions and assembly-only sharing remain separate. +The front-of-text opt-out is retained from the earlier implementation; this patch does not establish native Word equivalence between front-of-text and behind-text pictures. Synthetic placement tests cover the two paths, while a new native comparison of those wrap modes remains open. Turned-cell layouts disable horizontal cell confinement because their frame x axis becomes page y. + ## Revision history > 2026-11-01 · Text in a turned text box is drawn turned with it, about its centre, on screen and in PNG and PDF export; flipped across, upside down; flipped side to side, still readable (as Word draws it). Clicks and the caret inside a turned box still go by its upright text (`layout::tests::a_turned_text_box_turns_its_text_with_it`). @@ -285,6 +287,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-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. | | 2026-10-28 | trivial | Shared MATH construction records reuse variants and assembly data without changing glyph mappings or existing limits (#3630); readiness estimates unchanged | | 2026-11-01 | trivial | SEQ fields in text boxes are numbered where their boxes are anchored, in output and after F9, and a REF finds a bookmark in a text box |