From e5f2885ecc74facb6b174adfa3afc5742e5d6015 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Sun, 1 Nov 2026 19:11:56 +0000 Subject: [PATCH] Use the body boundary for header margin anchors (#2485) * T280: anchor header drawings below tall headers * #2485: share rendered header boundary with pins and obstacles * docs: layout-parity status line for #2485 --------- Co-authored-by: Brandon Thomas --- crates/layout/src/boxes.rs | 24 ++- crates/layout/src/ctx.rs | 12 +- crates/layout/src/floats.rs | 28 ++-- crates/layout/src/flow.rs | 4 +- crates/layout/src/headers.rs | 12 +- crates/layout/src/page.rs | 14 +- crates/layout/src/table_flow.rs | 2 +- crates/layout/src/tests.rs | 254 +++++++++++++++++++++++++++++++- docs/layout-parity.md | 10 +- 9 files changed, 322 insertions(+), 38 deletions(-) diff --git a/crates/layout/src/boxes.rs b/crates/layout/src/boxes.rs index 244565a7a..e766b0bd4 100644 --- a/crates/layout/src/boxes.rs +++ b/crates/layout/src/boxes.rs @@ -6,8 +6,8 @@ use super::page::PageFrame; use super::placed::Placed; use super::table_flow::below; use super::{ - Anchor, Arc, Block, Blocks, Border, Document, Float, FloatAlign, HashMap, ParaLayout, Paragraph, Path, Rect, SectionProps, StoryRef, TableFloat, - TextDirection, Wrap, WrapText, para, table, + Anchor, Arc, Block, Blocks, Border, Document, Float, FloatAlign, HashMap, ParaLayout, Paragraph, Path, Rect, StoryRef, TableFloat, TextDirection, + Wrap, WrapText, para, table, }; /// Cell identity is independent of optional table-style formatting. @@ -281,7 +281,8 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout { continue; } if let Some(pin) = ctx.pin.as_ref().filter(|pin| in_cell && pin.story == story && float.outside_cell && page_anchored(float)) { - let page = PageFrame { sect: &pin.sect, origin: (0.0, 0.0), number: pin.number, mirror: pin.mirror }; + let page = + PageFrame { sect: &pin.sect, margin_top: pin.margin_top, origin: (0.0, 0.0), number: pin.number, mirror: pin.mirror }; pinned.insert(oi, float_rect(Some(page), (0.0, width), para_top, w, h, float)); continue; } @@ -460,15 +461,22 @@ fn box_float_table_rect( _ => 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 page_y = |fr: &PageFrame| { + let s = fr.sect; + match f.v_rel { + Anchor::Page => Some((0.0, s.page_h)), + Anchor::Margin => { + let mut reference = s.clone(); + reference.margin_top = fr.margin_top.unwrap_or(s.margin_top); + Some((reference.margin_top, reference.text_height())) + } + _ => None, + } }; let h_area = frame.and_then(|fr| page_x(&fr).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))) { + let (v_area, y_off) = match frame.and_then(|fr| page_y(&fr).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), diff --git a/crates/layout/src/ctx.rs b/crates/layout/src/ctx.rs index 3be5036df..48107999c 100644 --- a/crates/layout/src/ctx.rs +++ b/crates/layout/src/ctx.rs @@ -89,6 +89,8 @@ struct ParaEnvInput<'a> { pub(crate) struct Pin { pub(crate) story: StoryRef, pub(crate) sect: SectionProps, + /// Rendered header Margin boundary; absent in footers and height probes. + pub(crate) margin_top: Option, pub(crate) number: u32, pub(crate) mirror: bool, pub(crate) behind: Vec, @@ -108,7 +110,14 @@ pub(crate) fn page_anchored(f: &Float) -> bool { /// Run `f`, a layout of header or footer `id` for page `number` of `sect`, with the floats in its /// table cells that are placed on the page put there (see [`Pin`]). Returns `f`'s result and those /// floats' items, in page coordinates: behind the header's text, and in front. -pub(crate) fn with_pin(ctx: &mut Ctx, id: u32, sect: &SectionProps, number: u32, f: impl FnOnce(&mut Ctx) -> T) -> (T, Vec, Vec) { +pub(crate) fn with_pin( + ctx: &mut Ctx, + id: u32, + sect: &SectionProps, + number: u32, + margin_top: Option, + f: impl FnOnce(&mut Ctx) -> T, +) -> (T, Vec, Vec) { let tables = ctx.doc.parts.get(&id).is_some_and(|p| p.blocks.iter().any(|b| matches!(**b, Block::Table(_)))); if !tables { return (f(ctx), Vec::new(), Vec::new()); @@ -116,6 +125,7 @@ pub(crate) fn with_pin(ctx: &mut Ctx, id: u32, sect: &SectionProps, number: u let pin = Pin { story: StoryRef::Part(id), sect: sect.clone(), + margin_top, number, mirror: ctx.mirror, behind: Vec::new(), diff --git a/crates/layout/src/floats.rs b/crates/layout/src/floats.rs index adea1ebcd..6a7d3551b 100644 --- a/crates/layout/src/floats.rs +++ b/crates/layout/src/floats.rs @@ -3,8 +3,8 @@ use super::page::{PageBuilder, PageFrame}; use super::placed::Placed; use super::table_flow::footer_table_wraps; use super::{ - Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, ParaLayout, Paragraph, Path, Point, Rect, - SectionProps, Spin, StoryRef, Wrap, WrapText, contour, display, para, textbox, + Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, ParaLayout, Paragraph, Path, Point, Rect, Spin, + StoryRef, Wrap, WrapText, contour, display, para, textbox, }; /// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden @@ -80,15 +80,23 @@ pub(crate) fn float_rect(frame: Option, col: (f32, f32), y0: f32, w: }; Some((start - shift, extent)) }; - let page_y = |s: &SectionProps| match float.v_rel { - Anchor::Page => Some((0.0, s.page_h)), - Anchor::Margin => Some((s.margin_top, s.page_h - s.margin_top - s.margin_bottom)), - Anchor::TopMargin | Anchor::InsideMargin => Some((0.0, s.margin_top)), - Anchor::BottomMargin | Anchor::OutsideMargin => Some((s.page_h - s.margin_bottom, s.margin_bottom)), - _ => None, + let page_y = |f: &PageFrame| { + let s = f.sect; + let top = f.margin_top.unwrap_or(s.margin_top); + match float.v_rel { + Anchor::Page => Some((0.0, s.page_h)), + Anchor::Margin => { + let mut reference = s.clone(); + reference.margin_top = top; + Some((top, reference.text_height())) + } + Anchor::TopMargin | Anchor::InsideMargin => Some((0.0, s.margin_top)), + Anchor::BottomMargin | Anchor::OutsideMargin => Some((s.page_h - s.margin_bottom, s.margin_bottom)), + _ => None, + } }; let hx = frame.and_then(|f| page_x(&f).map(|(a, e)| (a - f.origin.0, e))).unwrap_or(col); - let vy = frame.and_then(|f| page_y(f.sect).map(|(a, e)| (a - f.origin.1, e))).unwrap_or((y0, 0.0)); + let vy = frame.and_then(|f| page_y(&f).map(|(a, e)| (a - f.origin.1, e))).unwrap_or((y0, 0.0)); let h_align = PageFrame::h_align(frame.as_ref(), float.h_align); // Without a frame (a cell) `vy` has no height to align in: the object starts at the anchor // line instead of being pushed up by half its height or more. @@ -346,7 +354,7 @@ pub(crate) fn anchor_floats( // The paragraph's top, and whether text was placed before it on the page. let top = y0 - before; let text_before = top > pb.top + 0.5 || pb.col > 0; - let frame = PageFrame { sect: pb.sect, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }; + let frame = PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }; // An object left out of the layout (hidden, or deleted in the final text) is not placed, so // it takes no room either. let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.hide_deleted); diff --git a/crates/layout/src/flow.rs b/crates/layout/src/flow.rs index c0bf33d93..f8b093b5b 100644 --- a/crates/layout/src/flow.rs +++ b/crates/layout/src/flow.rs @@ -503,7 +503,7 @@ impl<'a> Flow<'a> { pb.para_end = Some((pb.pages.len(), pb.col, end)); // Pictures, shapes and text boxes in these lines (web view: floats placed by this piece). let at = ObjFrame { - page: Some(PageFrame { sect: pb.sect, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }), + page: Some(PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }), col: (x, self.width), para_y: if pb.web { y } else { self.anchor_y }, table_chr: None, @@ -800,7 +800,7 @@ pub(crate) fn place_outline_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragr let mut items = Vec::new(); push_para(&mut items, (StoryRef::Body, &[block as u32]), &pl, (0, l1), (x, y), width, edges); let at = ObjFrame { - page: Some(PageFrame { sect: pb.sect, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }), + page: Some(PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }), col: (x, width), para_y: y, table_chr: None, diff --git a/crates/layout/src/headers.rs b/crates/layout/src/headers.rs index 3b354573d..e6c22dc06 100644 --- a/crates/layout/src/headers.rs +++ b/crates/layout/src/headers.rs @@ -143,7 +143,7 @@ pub(crate) fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec { let Some(part) = ctx.doc.parts.get(&id) else { return Vec::new() }; let blocks = part.blocks.clone(); let x = sect.margin_left + sect.gutter; - let frame = PageFrame { sect, origin: (x, sect.header), number: page.number, mirror: ctx.mirror }; + let frame = PageFrame { sect, margin_top: Some(page.body.y), origin: (x, sect.header), number: page.number, mirror: ctx.mirror }; let base = hf.defining_width(false, page.section, Some(id)); let counters = std::mem::take(&mut ctx.counters); let boxes = std::mem::take(&mut ctx.boxes); @@ -298,9 +298,9 @@ pub(crate) fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(us && let Some(part) = ctx.doc.parts.get(&id) { let blocks = part.blocks.clone(); - let frame = PageFrame { sect, origin: (x, sect.header), number: page.number, mirror: ctx.mirror }; + let frame = PageFrame { sect, margin_top: Some(page.body.y), origin: (x, sect.header), number: page.number, mirror: ctx.mirror }; let base = hf.defining_width(false, page.section, hid); - let (mut items, behind, front) = with_pin(ctx, id, sect, page.number, |ctx| { + let (mut items, behind, front) = with_pin(ctx, id, sect, page.number, frame.margin_top, |ctx| { with_pct_base(ctx, base, |c| layout_box(c, BoxInput::other(StoryRef::Part(id), &blocks, w, 0, Some(frame))).items) }); for it in &mut items { @@ -315,11 +315,11 @@ pub(crate) fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(us let blocks = part.blocks.clone(); let base = hf.defining_width(true, page.section, fid); // The footer's height fixes where it sits, which page-relative floats need. - let (h, ..) = with_pin(ctx, id, sect, page.number, |ctx| { + let (h, ..) = with_pin(ctx, id, sect, page.number, None, |ctx| { with_pct_base(ctx, base, |c| layout_box(c, BoxInput::other(StoryRef::Part(id), &blocks, w, 0, None)).height) }); - let frame = PageFrame { sect, origin: (x, sect.page_h - sect.footer - h), number: page.number, mirror: ctx.mirror }; - let (mut items, behind, front) = with_pin(ctx, id, sect, page.number, |ctx| { + let frame = PageFrame { sect, margin_top: None, origin: (x, sect.page_h - sect.footer - h), number: page.number, mirror: ctx.mirror }; + let (mut items, behind, front) = with_pin(ctx, id, sect, page.number, frame.margin_top, |ctx| { with_pct_base(ctx, base, |c| layout_box(c, BoxInput::other(StoryRef::Part(id), &blocks, w, 0, Some(frame))).items) }); for it in &mut items { diff --git a/crates/layout/src/page.rs b/crates/layout/src/page.rs index ff6170f75..d885e6346 100644 --- a/crates/layout/src/page.rs +++ b/crates/layout/src/page.rs @@ -412,10 +412,11 @@ pub(crate) fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option { pub(crate) sect: &'a SectionProps, + /// Override the Margin anchor area without changing the declared margin bands. + pub(crate) margin_top: Option, /// Page position of the box's (0, 0); (0, 0) for body text. pub(crate) origin: (f32, f32), /// The page's number: inside and outside are left and right on odd pages, the other way @@ -504,5 +508,5 @@ impl PageFrame<'_> { /// The page `pb` is filling, for placing floating objects on it (`mirror`: [`Ctx::mirror`]). pub(crate) fn page_frame<'a>(pb: &PageBuilder<'a>, mirror: bool) -> PageFrame<'a> { - PageFrame { sect: pb.sect, origin: (0.0, 0.0), number: pb.number, mirror } + PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror } } diff --git a/crates/layout/src/table_flow.rs b/crates/layout/src/table_flow.rs index 76942f257..b8f5d7c0c 100644 --- a/crates/layout/src/table_flow.rs +++ b/crates/layout/src/table_flow.rs @@ -51,7 +51,7 @@ pub(crate) fn footer_table_wraps(ctx: &mut Ctx, pb: &PageBuilder) -> Vec Paragraph { + let mut p = Paragraph::with_text("One\nTwo\nThree\nFour\nFive", Default::default()); + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(10.0)); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); + p +} + +#[test] +fn header_boundary_pinned() { + use wordcraft_doc::para::{Anchor, Float, FloatAlign, Wrap}; + for anchor in [Anchor::Margin, Anchor::Page, Anchor::TopMargin] { + for align in [None, Some(FloatAlign::Center)] { + let mut doc = Document::from_text("Body"); + doc.last_section.margin_top = 60.0; + doc.last_section.header = 36.0; + let mut cell = Paragraph::with_text("Cell", Default::default()); + let float = Float { + wrap: Wrap::BehindText, + h_rel: Anchor::Page, + v_rel: anchor, + v_align: align, + outside_cell: true, + x: 300.0, + y: -20.0, + ..Default::default() + }; + cell.insert_object(0, rect_shape(40.0, 10.0, float), &Default::default()).unwrap(); + let mut table = Table::new(1, 1, 40.0); + table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell)]; + let header = doc.add_part( + wordcraft_doc::PartKind::Header, + vec![wordcraft_doc::para_block(boundary_header_para()), Arc::new(Block::Table(table.clone()))], + ); + let footer = doc.add_part(wordcraft_doc::PartKind::Footer, vec![Arc::new(Block::Table(table))]); + doc.last_section.headers.default = Some(header); + doc.last_section.footers.default = Some(footer); + let laid = lay(&doc); + let page = &laid.pages[0]; + assert!(page.body.y > doc.last_section.margin_top); + for (items, margin) in [(&page.header, page.body.y), (&page.footer, doc.last_section.margin_top)] { + let (start, extent) = match anchor { + Anchor::Margin => (margin, doc.last_section.page_h - margin - doc.last_section.margin_bottom), + Anchor::TopMargin => (0.0, doc.last_section.margin_top), + _ => (0.0, doc.last_section.page_h), + }; + let expected = match align { + Some(FloatAlign::Center) => start + (extent - 10.0) / 2.0, + _ => start - 20.0, + }; + let rects = shapes(items); + assert_eq!(rects.len(), 1); + assert!((rects[0].y - expected).abs() < 0.01, "{anchor:?} {align:?}: {:?}, expected {expected}, body {:?}", rects[0], page.body); + let hit = items + .iter() + .find_map(|item| match item { + Placed::Object { rect, .. } => Some(rect), + _ => None, + }) + .unwrap(); + assert!((hit.y - expected).abs() < 0.01); + } + } + } +} + +#[test] +fn header_boundary_obstacle() { + use wordcraft_doc::para::Anchor; + use wordcraft_doc::props::{HeightRule, TableFloat}; + const FLOAT_HEIGHT: f32 = 10.0; + for overlap in [false, true] { + let mut doc = Document::from_text("Body"); + doc.last_section.margin_top = 60.0; + doc.last_section.header = 36.0; + let mut header_table = Table::new(1, 1, 40.0); + header_table.rows[0].props.height = Some(FLOAT_HEIGHT); + header_table.rows[0].props.height_rule = HeightRule::Exact; + header_table.props.float = Some(TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Margin, x: 300.0, y: -20.0, ..Default::default() }); + let id = doc + .add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(boundary_header_para()), Arc::new(Block::Table(header_table))]); + doc.last_section.headers.default = Some(id); + let target = lay(&doc).pages[0].body.y - 20.0; + let mut body_table = Table::new(1, 1, 40.0); + body_table.rows[0].props.height = Some(FLOAT_HEIGHT); + body_table.rows[0].props.height_rule = HeightRule::Exact; + body_table.props.float = Some(TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Page, x: 300.0, y: target, overlap, ..Default::default() }); + doc.body.insert(0, Arc::new(Block::Table(body_table))); + let laid = lay(&doc); + let page = &laid.pages[0]; + let cell = |items: &[Placed]| { + items + .iter() + .find_map(|item| match item { + Placed::Cell { rect, .. } => Some(*rect), + _ => None, + }) + .unwrap() + }; + let (header, body) = (cell(&page.header), cell(&page.items)); + assert!(page.body.y > doc.last_section.margin_top); + assert!((header.y - target).abs() < 0.01); + if overlap { + assert!((body.y - target).abs() < 0.01, "overlap retains requested position: {body:?}"); + } else { + assert!(body.y >= header.bottom() - 0.01, "body {body:?} overlaps rendered header {header:?}"); + } + } +} + +#[test] +fn header_margin_tracks_body_top() { + use wordcraft_doc::para::{Anchor, Float, Wrap}; + use wordcraft_doc::props::LineSpacing; + for margin in [60.0, 120.0] { + for anchor in [Anchor::Margin, Anchor::Page, Anchor::Paragraph] { + let mut d = Document::from_text("Body"); + d.last_section.margin_top = margin; + d.last_section.header = 36.0; + let mut hp = Paragraph::with_text("One\nTwo\nThree\nFour\nFive", Default::default()); + hp.props.line_spacing = Some(LineSpacing::Exactly(10.0)); + hp.props.space_before = Some(0.0); + hp.props.space_after = Some(0.0); + let float = Float { wrap: Wrap::BehindText, h_rel: Anchor::Page, v_rel: anchor, x: 300.0, y: -20.0, ..Default::default() }; + hp.insert_object(0, rect_shape(40.0, 10.0, float), &Default::default()).unwrap(); + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]); + d.last_section.headers.default = Some(id); + let l = lay(&d); + let page = &l.pages[0]; + let expected = match anchor { + Anchor::Margin => page.body.y - 20.0, + Anchor::Page => -20.0, + _ => 16.0, + }; + assert!( + (shapes(&page.header)[0].y - expected).abs() < 0.01, + "margin {margin}, anchor {anchor:?}: {:?}, body {:?}", + shapes(&page.header), + page.body + ); + } + } +} + +#[test] +fn header_anchor_extents_stay() { + use wordcraft_doc::para::{Anchor, Float, FloatAlign, Wrap}; + use wordcraft_doc::props::LineSpacing; + for margin in [60.0, 120.0] { + for anchor in + [Anchor::Margin, Anchor::TopMargin, Anchor::InsideMargin, Anchor::Page, Anchor::Paragraph, Anchor::BottomMargin, Anchor::OutsideMargin] + { + for align in [None, Some(FloatAlign::Start), Some(FloatAlign::Center), Some(FloatAlign::End)] { + let mut d = Document::from_text("Body"); + d.last_section.margin_top = margin; + d.last_section.header = 36.0; + let mut hp = Paragraph::with_text("One\nTwo\nThree\nFour\nFive", Default::default()); + hp.props.line_spacing = Some(LineSpacing::Exactly(10.0)); + hp.props.space_before = Some(0.0); + hp.props.space_after = Some(0.0); + let float = + Float { wrap: Wrap::BehindText, h_rel: Anchor::Page, v_rel: anchor, v_align: align, x: 300.0, y: -20.0, ..Default::default() }; + hp.insert_object(0, rect_shape(40.0, 10.0, float), &Default::default()).unwrap(); + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]); + d.last_section.headers.default = Some(id); + let l = lay(&d); + let page = &l.pages[0]; + if margin == 60.0 { + assert!(page.body.y > margin, "the header must displace the body"); + } else { + assert!((page.body.y - margin).abs() < 0.01, "the header must fit within the declared margin"); + } + let s = &d.last_section; + let (start, extent) = match anchor { + Anchor::Margin => (page.body.y, s.page_h - page.body.y - s.margin_bottom), + Anchor::TopMargin | Anchor::InsideMargin => (0.0, margin), + Anchor::Page => (0.0, s.page_h), + Anchor::BottomMargin | Anchor::OutsideMargin => (s.page_h - s.margin_bottom, s.margin_bottom), + _ => (s.header, 0.0), + }; + let expected = match align { + None => start - 20.0, + Some(FloatAlign::Center) => start + (extent - 10.0) / 2.0, + Some(FloatAlign::End) => start + extent - 10.0, + _ => start, + }; + let drawn = shapes(&page.header); + assert_eq!(drawn.len(), 1, "the countercontrol must place its drawing"); + assert!( + (drawn[0].y - expected).abs() < 0.01, + "margin {margin}, anchor {anchor:?}, align {align:?}: {:?}, expected {expected}, body {:?}", + drawn, + page.body + ); + } + } + } +} + +#[test] +fn header_margin_table_area() { + use wordcraft_doc::para::{Anchor, FloatAlign}; + use wordcraft_doc::props::{HeightRule, LineSpacing, TableFloat}; + for margin in [60.0, 120.0] { + for align in [None, Some(FloatAlign::Center), Some(FloatAlign::End)] { + let mut d = Document::from_text("Body"); + d.last_section.margin_top = margin; + d.last_section.header = 36.0; + let mut hp = Paragraph::with_text("One\nTwo\nThree\nFour\nFive", Default::default()); + hp.props.line_spacing = Some(LineSpacing::Exactly(10.0)); + hp.props.space_before = Some(0.0); + hp.props.space_after = Some(0.0); + let mut t = Table::new(1, 1, 40.0); + t.rows[0].props.height = Some(10.0); + t.rows[0].props.height_rule = HeightRule::Exact; + t.props.float = Some(TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Margin, v_align: align, x: 300.0, y: -20.0, ..Default::default() }); + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp), Arc::new(Block::Table(t))]); + d.last_section.headers.default = Some(id); + let l = lay(&d); + let page = &l.pages[0]; + if margin == 60.0 { + assert!(page.body.y > margin, "the header must displace the body"); + } + let cells: Vec<_> = page + .header + .iter() + .filter_map(|it| match it { + Placed::Cell { rect, .. } => Some(rect), + _ => None, + }) + .collect(); + assert_eq!(cells.len(), 1); + let extent = (d.last_section.page_h - page.body.y - d.last_section.margin_bottom).max(18.0); + let expected = match align { + Some(FloatAlign::Center) => page.body.y + (extent - 10.0) / 2.0, + Some(FloatAlign::End) => page.body.y + extent - 10.0, + _ => page.body.y - 20.0, + }; + assert!((cells[0].y - expected).abs() < 0.01, "margin {margin}, align {align:?}: {:?}, expected {expected}", cells[0]); + } + } +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 112a5d964..3037fb366 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-10-29 · **Change:** 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-01 · **Change:** 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, @@ -234,6 +234,10 @@ Explicit cell page breaks (Word for Mac 16.113.2, compatibility modes 14 and 15) Footer flow-table strokes reserve their drawn top edge, including the half-width above the cell box. Original Word for Mac 16.113.2 mode-15 controls confirm the three footer cases move to the next page. The 3 pt row-end border case without a footer still differs. Tight row-end and continuation border fitting remain separate. +## Header margin anchors + +When a header displaces the body, Margin-anchored drawings use the rendered body top. Pinned header-cell floats and floating-header-table obstacles use the same boundary. Page anchors retain their declared extent; TopMargin and InsideMargin anchors retain their declared top-margin extent; footer frames and height probes have no body-top override. Original model controls cover positioning and allowed/disallowed table overlap. Earlier native observations remain historical; this repair adds no native or corpus qualification. + ## Footer page tables Page-relative footer tables keep their physical wrap bounds without adding to footer text height. Body text resumes below the table. Original native controls cover empty, text-bearing and minimum-height tables. Body textbox boundaries and nested footer painting remain separate gaps. @@ -272,11 +276,14 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | #2485: Port header Margin body-boundary placement to current modules; use the existing text-height floor for inline Margin extents and correct historical control labels. Source checks only; primary qualification pending. | | 2026-10-30 | trivial | Keep with next: a kept heading before a three-line paragraph under widow/orphan control needs room for all three lines, since that paragraph can only move over whole; with room for two, the heading goes to the next page with it instead of staying alone at the bottom, as in Word for Mac 16.93. `layout::tests::keep_with_next_carries_the_heading_when_a_three_line_paragraph_moves_whole` | | 2026-10-28 | trivial | Connector object lookups retain the typed column-selection inputs on current main (#3631); readiness estimates unchanged | | 2026-10-27 | trivial | Connector rerouting indexes laid-out object positions once per batch, preserving page hints, first-match order and same-page glue; synthetic equivalence, geometry and scaling regressions (#3631); routing policy and readiness estimates unchanged | | 2026-10-29 | trivial | A cell spanning columns shares its extra minimum width by the columns' preferred widths (equally in automatic tables); nested tables are measured with the same weights (#1883) | | 2026-10-25 | trivial | Field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches in table formulas and in the page-number, SEQ, PAGEREF and count fields (#4226); readiness estimates unchanged | +| 2026-10-25 | trivial | Header-cell pins and header-table obstacles share the rendered body-top override (#2485); model positioning and overlap controls, no new native comparison. | +| 2026-10-19 | trivial | Header body-boundary adjustment applies only to the Margin anchor. TopMargin and InsideMargin keep their declared extent; synthetic alignment countercontrols are prepared, not yet executed. | | 2026-10-25 | 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`) | | 2026-10-24 | trivial | Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; original emoji regression retained, new mixed-style/bundled-font LTR/bidi controls pending managed validation | | 2026-10-24 | trivial | Exact-spacing table contact uses the specified line height in row selection and word skipping; large/small mark regressions in modes 11/12/14/15 (#3977); shape estimates unchanged | @@ -327,6 +334,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | 2026-10-16 | trivial | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. | | 2026-10-16 | trivial | `w:autoSpaceDE` and `w:autoSpaceDN` are laid out: a quarter em between Asian text and Latin text or digits (#2358) | | 2026-10-16 | trivial | AutoFit donors lose equal widths, bounded by content minima | +| 2026-10-16 | trivial | Header margin anchors use the body boundary when header text extends below the declared top margin. Native Margin, Page and Paragraph anchor controls; synthetic TopMargin, InsideMargin and floating-table controls; historical local Word and Adobe references. | | 2026-10-16 | trivial | Behind-text pictures in table cells participate in centre and bottom vertical alignment without increasing row flow height. | | 2026-10-15 | trivial | Absent or zero DrawingML effect extents remain zero on rotated pictures; contracted axes no longer reserve invented effects room. Synthetic quarter-turn import regression (`zero_effects_stay_zero_rotated`). | | 2026-10-15 | trivial | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. |