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 <echelon@gmail.com>
This commit is contained in:
Fin WarmanandBrandon Thomas authored and GitHub committed 2026-11-01 14:11:56 -05:00
1 parent 5cd53f184a
commit e5f2885ecc
9 files changed
+322 -38

No files matched your search

+16 -8
View File
@@ -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),
+11 -1
View File
@@ -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<f32>,
pub(crate) number: u32,
pub(crate) mirror: bool,
pub(crate) behind: Vec<Placed>,
@@ -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<T>(ctx: &mut Ctx, id: u32, sect: &SectionProps, number: u32, f: impl FnOnce(&mut Ctx) -> T) -> (T, Vec<Placed>, Vec<Placed>) {
pub(crate) fn with_pin<T>(
ctx: &mut Ctx,
id: u32,
sect: &SectionProps,
number: u32,
margin_top: Option<f32>,
f: impl FnOnce(&mut Ctx) -> T,
) -> (T, Vec<Placed>, Vec<Placed>) {
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<T>(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(),
+18 -10
View File
@@ -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<PageFrame>, 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);
+2 -2
View File
@@ -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,
+6 -6
View File
@@ -143,7 +143,7 @@ pub(crate) fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
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 {
+9 -5
View File
@@ -412,10 +412,11 @@ pub(crate) fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option<u3
let Some(part) = ctx.doc.parts.get(&id) else { return sect.margin_top };
let blocks = part.blocks.clone();
// Placed as `headers_footers` draws it, so page-relative floats wrap the same way.
let frame = PageFrame { sect, origin: (sect.margin_left + sect.gutter, sect.header), number: 1, mirror: ctx.mirror };
let frame = PageFrame { sect, margin_top: None, origin: (sect.margin_left + sect.gutter, sect.header), number: 1, mirror: ctx.mirror };
let track = std::mem::replace(&mut ctx.track_totals, false);
let (h, ..) =
with_pin(ctx, id, sect, 1, |ctx| layout_box(ctx, BoxInput::other(StoryRef::Part(id), &blocks, sect.text_width(), 0, Some(frame))).height);
let (h, ..) = with_pin(ctx, id, sect, 1, None, |ctx| {
layout_box(ctx, BoxInput::other(StoryRef::Part(id), &blocks, sect.text_width(), 0, Some(frame))).height
});
ctx.track_totals = track;
// Word starts the body right below a header that reaches past the top margin, no gap.
sect.margin_top.max(sect.header + h)
@@ -428,7 +429,8 @@ pub(crate) fn foot_top_for(ctx: &mut Ctx, sect: &SectionProps, footer: Option<u3
// A page total in the footer is filled in after pagination, like the header's: it doesn't
// make the body lay out again.
let track = std::mem::replace(&mut ctx.track_totals, false);
let (laid, ..) = with_pin(ctx, id, sect, 1, |ctx| layout_box(ctx, BoxInput::other(StoryRef::Part(id), &blocks, sect.text_width(), 0, None)));
let (laid, ..) =
with_pin(ctx, id, sect, 1, None, |ctx| layout_box(ctx, BoxInput::other(StoryRef::Part(id), &blocks, sect.text_width(), 0, None)));
ctx.track_totals = track;
let top = sect.page_h - sect.footer - laid.height;
// Flow-table strokes extend above their cell boxes. Floating decorations keep their wrap rules.
@@ -470,6 +472,8 @@ pub(crate) fn foot_top_for(ctx: &mut Ctx, sect: &SectionProps, footer: Option<u3
#[derive(Clone, Copy)]
pub(crate) struct PageFrame<'a> {
pub(crate) sect: &'a SectionProps,
/// Override the Margin anchor area without changing the declared margin bands.
pub(crate) margin_top: Option<f32>,
/// 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 }
}
+1 -1
View File
@@ -51,7 +51,7 @@ pub(crate) fn footer_table_wraps(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<WrapAre
let story = StoryRef::Part(id);
let h = layout_box(ctx, BoxInput::other(story, &blocks, sect.text_width(), 0, None)).height;
let y = sect.page_h - sect.footer - h;
let frame = PageFrame { sect, origin: (x, y), number: page.number, mirror: ctx.mirror };
let frame = PageFrame { sect, margin_top: None, origin: (x, y), number: page.number, mirror: ctx.mirror };
let mut areas = layout_box(ctx, BoxInput::other(story, &blocks, sect.text_width(), 0, Some(frame))).page_table_wraps;
for (rect, ..) in &mut areas {
rect.x += x;
+250 -4
View File
@@ -5466,12 +5466,15 @@ fn header_cell_float_placed_on_the_page() {
let header = |d: &mut Document, outside_cell: bool| {
let mut t = Table::new(1, 2, 468.0);
let mut cp = wordcraft_doc::Paragraph::with_text("Right cell", Default::default());
// This page-centering control keeps its header within the declared margin.
cp.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(12.0));
cp.props.space_before = Some(0.0);
cp.props.space_after = Some(0.0);
let tail = wordcraft_doc::Paragraph { props: cp.props.clone(), ..Default::default() };
cp.insert_object(0, rect_shape(360.0, 288.0, wm(outside_cell)), &Default::default()).unwrap();
t.rows[0].cells[1].blocks = vec![wordcraft_doc::para_block(cp)];
let id = d.add_part(
wordcraft_doc::PartKind::Header,
vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t)), wordcraft_doc::para_block(Default::default())],
);
let id =
d.add_part(wordcraft_doc::PartKind::Header, vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t)), wordcraft_doc::para_block(tail)]);
d.last_section.headers.default = Some(id);
};
header(&mut d, true);
@@ -12091,3 +12094,246 @@ fn an_empty_bulleted_mark_after_a_bulleted_section_closer_draws_no_bullet() {
assert!(text, "{filler} lines above: the text closer keeps its bullet");
}
}
fn boundary_header_para() -> 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]);
}
}
}
+9 -1
View File
@@ -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 incluLine truncated
> **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: exLine truncated
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. |