mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 01:08:04 +00:00
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:
1 parent
5cd53f184a
commit
e5f2885ecc
9 files changed
+322
-38
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user