Layout: inside/outside floats swap sides on even pages; Page Setup shows mirror margins (#1181) (#1239)

* Layout: inside/outside floats swap sides on even pages; Page Setup shows mirror margins (#1181)

Floats aligned inside or outside, or placed relative to the inside or
outside margin, were always put as on an odd page, so shapes Word puts
in a book's outside margin all came out on the right. They now follow
the page they're on (ECMA-376 ST_AlignH, ST_RelFromH): on an even page
the inside is the right side. Floating tables (w:tblpXSpec) too.

With mirrored margins, even pages are laid out as odd ones and then
shifted; float positions now allow for that shift, so floats placed
from the page edge stay put (the "known limit" in #452) and the left
and right margin areas are each page's own.

Mirrored margins read from a file were invisible in the UI: the Margins
menu ticks Mirrored when they're on, and Page Setup gets a Mirror
margins check box (labelling the margins Inside and Outside), applied
through a new `mirror` param of layout.margins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: Mirror margins in every catalog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
LloydNicholson
2026-10-14 18:57:44 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 lloyd Brandon Thomas
parent cf140ae600
commit e0a1346590
27 changed files with 286 additions and 47 deletions
+5 -4
View File
@@ -12,7 +12,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("layout.margins", "Margins", "Layout › Page Setup", margins).params(
r#"{"preset"?: "normal|narrow|moderate|wide|mirrored|office2003", "top"?: pt, "bottom"?: pt, "left"?: pt, "right"?: pt, "gutter"?: pt}"#,
r#"{"preset"?: "normal|narrow|moderate|wide|mirrored|office2003", "top"?: pt, "bottom"?: pt, "left"?: pt, "right"?: pt, "gutter"?: pt, "mirror"?: bool}"#,
),
CommandSpec::new("layout.orientation", "Orientation", "Layout › Page Setup", |s, v| {
let land = match p::str(v, "value") {
@@ -396,9 +396,10 @@ fn margins(s: &mut Session, v: &Value) -> CmdResult {
if !margins_fit((cur.page_w, cur.page_h), (t, b, l, r), gutter.unwrap_or(cur.gutter)) {
return Err(CmdError::Params("margins leave no room for text".into()));
}
// A preset picks mirrored or not; plain numbers (a ruler drag, an agent) keep the setting.
if preset.is_some() {
s.doc.settings.mirror_margins = preset == Some("mirrored");
// A preset picks mirrored or not, as does `mirror` (Page Setup's Multiple pages); plain numbers
// alone (a ruler drag, an agent) keep the setting.
if let Some(on) = p::bool(v, "mirror").or(preset.map(|k| k == "mirrored")) {
s.doc.settings.mirror_margins = on;
}
with_sect(s, |x| {
x.margin_top = t.max(0.0);
+9
View File
@@ -1625,6 +1625,15 @@ fn page_setup_and_breaks() {
assert_eq!(s.doc.sections().len(), 2);
assert_eq!(s.layout().pages.len(), 2);
assert!(s.run("layout.margins", &json!({"left": 1000.0})).is_err());
// Mirrored margins: from the preset or `mirror` (#1181); plain numbers keep the setting.
run(&mut s, "layout.margins", json!({"preset": "mirrored"}));
assert!(s.doc.settings.mirror_margins);
run(&mut s, "layout.margins", json!({"left": 80.0}));
assert!(s.doc.settings.mirror_margins);
run(&mut s, "layout.margins", json!({"left": 80.0, "mirror": false}));
assert!(!s.doc.settings.mirror_margins && s.doc.last_section.margin_left == 80.0);
run(&mut s, "layout.margins", json!({"mirror": true}));
assert!(s.doc.settings.mirror_margins);
}
/// #1433: a custom page size (More Paper Sizes…) sets the section in its orientation, and
+91 -35
View File
@@ -650,6 +650,8 @@ struct Ctx<'a> {
/// Body blocks that are empty paragraphs carrying only a section break before a continuous
/// section, in order: they take no room (see [`place_para`]).
quiet_marks: Vec<usize>,
/// Whether even pages are mirrored (mirrored margins, on pages): see [`mirror_even_pages`].
mirror: bool,
/// Cache-key hashes of the table-style text formatting cells were laid out with
/// ([`Ctx::table_key`]).
table_keys: Vec<(para::CellText, u64)>,
@@ -1227,17 +1229,21 @@ fn box_float_table_rect(
) -> (Rect, Rect) {
let h = tl.rows.iter().map(|r| r.height).sum::<f32>().clamp(0.0, 100_000.0);
let w = tl.width.clamp(1.0, 100_000.0);
let page_x = |s: &SectionProps| match f.h_rel {
Anchor::Page => Some((0.0, s.page_w)),
Anchor::Margin => Some((s.margin_left + s.gutter, s.text_width())),
_ => None,
// Across, as laid out before an even page is mirrored (see `float_rect`).
let page_x = |fr: &PageFrame| {
let (left, _, shift) = fr.sides();
match f.h_rel {
Anchor::Page => Some((-shift, fr.sect.page_w)),
Anchor::Margin => Some((left - shift, fr.sect.text_width())),
_ => 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 h_area = frame.and_then(|fr| page_x(fr.sect).map(|(a, e)| (a - fr.origin.0, e))).unwrap_or((0.0, width));
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))) {
@@ -1247,7 +1253,7 @@ fn box_float_table_rect(
};
// Nor is there an area to align in: the table starts where the text is.
let v_align = if frame.is_none() && matches!(f.v_rel, Anchor::Page | Anchor::Margin) { None } else { f.v_align };
let x = align_in(h_area, f.x, w, f.h_align) - if f.h_align.is_none() { legacy } else { 0.0 };
let x = align_in(h_area, f.x, w, PageFrame::h_align(frame.as_ref(), f.h_align)) - if f.h_align.is_none() { legacy } else { 0.0 };
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy, h_area.0, float_tables);
let [dl, dt, dr, db] = f.dist_from_text();
(r, r.inset(-dl, -dt, -dr, -db))
@@ -1936,12 +1942,14 @@ fn layout_pass(
uses_totals: false,
track_totals: true,
quiet_marks: Vec::new(),
mirror: false,
table_keys: Vec::new(),
};
let sections = doc.sections();
let web = matches!(opts.view, ViewMode::Web | ViewMode::Draft | ViewMode::Outline);
let read = opts.view == ViewMode::Read;
let reflow = web || read;
ctx.mirror = doc.settings.mirror_margins && !reflow;
ctx.quiet_marks = sections
.windows(2)
.filter(|w| w[1].1.start == SectionStart::Continuous)
@@ -2478,7 +2486,7 @@ fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option<u32>) -> f32
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) };
let frame = PageFrame { sect, 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 = layout_box(ctx, StoryRef::Part(id), &blocks, &[], sect.text_width(), BoxContext::Other, 0, Some(frame)).height;
ctx.track_totals = track;
@@ -2505,6 +2513,34 @@ struct PageFrame<'a> {
sect: &'a SectionProps,
/// Page position of the box's (0, 0); (0, 0) for body text.
origin: (f32, f32),
/// The page's number: inside and outside are left and right on odd pages, the other way
/// round on even ones.
number: u32,
/// Whether an even page is mirrored ([`mirror_even_pages`]).
mirror: bool,
}
impl PageFrame<'_> {
fn even(&self) -> bool {
self.number.is_multiple_of(2)
}
/// The widths of the page's left and right margins (the gutter is on the inside), and how
/// far [`mirror_even_pages`] moves what is laid out on it.
fn sides(&self) -> (f32, f32, f32) {
let (inside, outside) = (self.sect.margin_left + self.sect.gutter, self.sect.margin_right);
let shift = outside - inside;
if self.mirror && self.even() && shift.is_finite() { (outside, inside, shift) } else { (inside, outside, 0.0) }
}
/// Alignment `a` across the page: inside and outside become right and left on an even page.
fn h_align(frame: Option<&Self>, a: Option<FloatAlign>) -> Option<FloatAlign> {
match a {
Some(FloatAlign::Inside) if frame.is_some_and(|f| f.even()) => Some(FloatAlign::End),
Some(FloatAlign::Outside) if frame.is_some_and(|f| f.even()) => Some(FloatAlign::Start),
a => a,
}
}
}
/// A floating (not inline) picture, chart or shape: its size and placement.
@@ -2518,16 +2554,22 @@ fn floating(o: &InlineObject) -> Option<(f32, f32, &Float)> {
/// to the box itself.
fn float_rect(frame: Option<PageFrame>, col: (f32, f32), y0: f32, w: f32, h: f32, float: &Float) -> Rect {
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
// Each axis: the reference area's start and extent, in page coordinates.
let page_x = |s: &SectionProps| {
let left = s.margin_left + s.gutter;
match float.h_rel {
Anchor::Page => Some((0.0, s.page_w)),
Anchor::Margin => Some((left, s.text_width())),
Anchor::LeftMargin | Anchor::InsideMargin => Some((0.0, left)),
Anchor::RightMargin | Anchor::OutsideMargin => Some((s.page_w - s.margin_right, s.margin_right)),
_ => None,
}
// Each axis: the reference area's start and extent, in page coordinates. Across, as laid out
// before an even page is mirrored (less the shift it then gets).
let page_x = |f: &PageFrame| {
let (s, even) = (f.sect, f.even());
let (left, right, shift) = f.sides();
let (start, extent) = match float.h_rel {
Anchor::Page => (0.0, s.page_w),
Anchor::Margin => (left, s.text_width()),
Anchor::LeftMargin => (0.0, left),
Anchor::RightMargin => (s.page_w - right, right),
Anchor::InsideMargin if even => (s.page_w - right, right),
Anchor::OutsideMargin if !even => (s.page_w - right, right),
Anchor::InsideMargin | Anchor::OutsideMargin => (0.0, left),
_ => return None,
};
Some((start - shift, extent))
};
let page_y = |s: &SectionProps| match float.v_rel {
Anchor::Page => Some((0.0, s.page_h)),
@@ -2536,9 +2578,10 @@ fn float_rect(frame: Option<PageFrame>, col: (f32, f32), y0: f32, w: f32, h: f32
Anchor::BottomMargin | Anchor::OutsideMargin => Some((s.page_h - s.margin_bottom, s.margin_bottom)),
_ => None,
};
let hx = frame.and_then(|f| page_x(f.sect).map(|(a, e)| (a - f.origin.0, e))).unwrap_or(col);
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));
Rect::new(align_in(hx, float.x, w, float.h_align), align_in(vy, float.y, h, float.v_align), w, h)
let h_align = PageFrame::h_align(frame.as_ref(), float.h_align);
Rect::new(align_in(hx, float.x, w, h_align), align_in(vy, float.y, h, float.v_align), w, h)
}
/// Where an object of `size` starts on one axis of `area` (start, extent): at `offset` from the
@@ -2687,7 +2730,7 @@ fn anchor_floats(
let (col_x, width) = (pb.col_x(), pb.col_w());
let mut rects = HashMap::new();
if !pb.web {
let frame = PageFrame { sect: pb.sect, origin: (0.0, 0.0) };
let frame = PageFrame { sect: pb.sect, 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);
@@ -3198,7 +3241,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
push_para(&mut items, StoryRef::Body, &[block as u32], &pl, l0, l1, x, y, width);
// 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) }),
page: Some(PageFrame { sect: pb.sect, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }),
col: (x, width),
para_y: if pb.web { y } else { anchor_y },
table_chr: None,
@@ -3331,7 +3374,12 @@ fn place_outline_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
let y = pb.y + pl.lines.first().map(|f| f.top.max(0.0)).unwrap_or(0.0);
let mut items = Vec::new();
push_para(&mut items, StoryRef::Body, &[block as u32], &pl, 0, l1, x, y, width);
let at = ObjFrame { page: Some(PageFrame { sect: pb.sect, origin: (0.0, 0.0) }), col: (x, width), para_y: y, table_chr: None };
let at = ObjFrame {
page: Some(PageFrame { sect: pb.sect, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }),
col: (x, width),
para_y: y,
table_chr: None,
};
let (behind, front) = place_objects(ctx, StoryRef::Body, &[block as u32], &q, &pl, (0, l1), (x, y), &at, &HashMap::new(), 0);
items.extend(front);
let children = level < outline::BODY && outline::has_children(&o.levels, block);
@@ -3446,12 +3494,12 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 };
let h: f32 = tl.rows.iter().map(|r| r.height).sum();
if h <= pb.uncapped_bottom() - pb.top + 0.01 {
place_floating_table(pb, &tl, &f, legacy, block, body_top);
place_floating_table(pb, &tl, &f, legacy, ctx.mirror, block, body_top);
return;
}
// Too tall for a page: Word lets it run across pages like a table in the text, from its
// own left edge.
tl.x = float_table_x(pb, tl.width, &f, legacy) - pb.col_x();
tl.x = float_table_x(pb, tl.width, &f, legacy, ctx.mirror) - pb.col_x();
}
// A table in the text flow starts below the floating objects in its way.
below_floats(pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width);
@@ -3553,20 +3601,20 @@ fn table_notes(ctx: &mut Ctx, pb: &mut PageBuilder, items: &[Placed]) {
/// Place floating table `tl` (`w:tblpPr`): at its own position, which takes no room in the text
/// flow; the text after it wraps around it.
fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &TableFloat, legacy: f32, block: usize, body_top: f32) {
fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &TableFloat, legacy: f32, mirror: bool, block: usize, body_top: f32) {
let h: f32 = tl.rows.iter().map(|r| r.height).sum::<f32>().clamp(0.0, 100_000.0);
let w = tl.width.clamp(1.0, 100_000.0);
let [dl, dt, dr, db] = f.dist_from_text();
for attempt in 0..2 {
let s = pb.sect;
let h_area = float_table_h_area(pb, f);
let h_area = float_table_h_area(pb, f, mirror);
// The text a table anchored to it stands in: where the next paragraph starts.
let v_area = match f.v_rel {
Anchor::Page => (0.0, s.page_h),
Anchor::Margin => (s.margin_top, s.text_height()),
_ => (pb.y, 0.0),
};
let x = float_table_x(pb, w, f, legacy);
let x = float_table_x(pb, w, f, legacy, mirror);
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), f, legacy, h_area.0, &pb.float_tables);
// Like Word, a table that doesn't fit below the text before it moves to the next page with
// its anchor: one placed relative to the paragraph when it would run off the page, one
@@ -3599,20 +3647,28 @@ fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &Table
}
}
/// The area a floating table's `x` is measured in: its start and width on the page.
fn float_table_h_area(pb: &PageBuilder, f: &TableFloat) -> (f32, f32) {
/// The page `pb` is filling, for placing floating objects on it (`mirror`: [`Ctx::mirror`]).
fn page_frame<'a>(pb: &PageBuilder<'a>, mirror: bool) -> PageFrame<'a> {
PageFrame { sect: pb.sect, origin: (0.0, 0.0), number: pb.number, mirror }
}
/// The area a floating table's `x` is measured in: its start and width on the page, as laid out
/// before an even page is mirrored (see [`float_rect`]).
fn float_table_h_area(pb: &PageBuilder, f: &TableFloat, mirror: bool) -> (f32, f32) {
let s = pb.sect;
let (left, _, shift) = page_frame(pb, mirror).sides();
match f.h_rel {
Anchor::Page => (0.0, s.page_w),
Anchor::Margin => (s.margin_left + s.gutter, s.text_width()),
Anchor::Page => (-shift, s.page_w),
Anchor::Margin => (left - shift, s.text_width()),
_ => (pb.col_x(), pb.col_w()),
}
}
/// A floating table's left edge on the page (`legacy`: the first cell's left margin before Word
/// 2013 layout, when an offset places the cell's text).
fn float_table_x(pb: &PageBuilder, w: f32, f: &TableFloat, legacy: f32) -> f32 {
let x = align_in(float_table_h_area(pb, f), f.x, w, f.h_align);
fn float_table_x(pb: &PageBuilder, w: f32, f: &TableFloat, legacy: f32, mirror: bool) -> f32 {
let align = PageFrame::h_align(Some(&page_frame(pb, mirror)), f.h_align);
let x = align_in(float_table_h_area(pb, f, mirror), f.x, w, align);
if f.h_align.is_none() { x - legacy } else { x }
}
@@ -3782,7 +3838,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
&& let Some(part) = ctx.doc.parts.get(&id)
{
let blocks = part.blocks.clone();
let frame = PageFrame { sect, origin: (x, sect.header) };
let frame = PageFrame { sect, origin: (x, sect.header), number: page.number, mirror: ctx.mirror };
let mut items = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, BoxContext::Other, 0, Some(frame)).items;
for it in &mut items {
it.translate(x, sect.header);
@@ -3796,7 +3852,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
let blocks = part.blocks.clone();
// The footer's height fixes where it sits, which page-relative floats need.
let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, BoxContext::Other, 0, None).height;
let frame = PageFrame { sect, origin: (x, sect.page_h - sect.footer - h) };
let frame = PageFrame { sect, origin: (x, sect.page_h - sect.footer - h), number: page.number, mirror: ctx.mirror };
let mut items = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, BoxContext::Other, 0, Some(frame)).items;
for it in &mut items {
it.translate(x, sect.page_h - sect.footer - h);
+2 -2
View File
@@ -3103,7 +3103,7 @@ fn lines_with_tabs_never_shrink() {
}
}
fn rect_shape(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
pub(super) fn rect_shape(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w,
@@ -3119,7 +3119,7 @@ fn rect_shape(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject
}
}
fn shapes(items: &[Placed]) -> Vec<Rect> {
pub(super) fn shapes(items: &[Placed]) -> Vec<Rect> {
items.iter().filter_map(|i| if let Placed::Shape { rect, .. } = i { Some(*rect) } else { None }).collect()
}
+104
View File
@@ -53,6 +53,110 @@ fn mirrored_margins_put_the_inside_margin_toward_the_binding() {
}
}
#[test]
fn inside_and_outside_floats_swap_sides_on_even_pages() {
// #1181: shapes Word places in the outside margin of a book (mirrored margins) all came out
// in the right margin. Inside 108 plus a 10pt gutter, outside 36; the paragraph on page 2
// carries the same six shapes as page 1's, told apart by their widths.
use super::tests::{rect_shape, shapes};
use wordcraft_doc::para::{Anchor, Float, FloatAlign, Wrap};
let mut d = Document::from_text("Odd page\nEven page");
d.last_section.margin_left = 108.0;
d.last_section.margin_right = 36.0;
d.last_section.gutter = 10.0;
d.settings.mirror_margins = true;
if let Some(Block::Para(p)) = d.body.get_mut(1).map(Arc::make_mut) {
p.props.page_break_before = Some(true);
}
let floats = [
(Anchor::Margin, Some(FloatAlign::Outside), 0.0),
(Anchor::OutsideMargin, Some(FloatAlign::Center), 0.0),
(Anchor::InsideMargin, Some(FloatAlign::Center), 0.0),
(Anchor::Page, Some(FloatAlign::Inside), 0.0),
(Anchor::Page, None, 10.0),
(Anchor::LeftMargin, Some(FloatAlign::End), 0.0),
];
for block in 0..2 {
for (i, (h_rel, h_align, x)) in floats.iter().enumerate() {
let f = Float { wrap: Wrap::InFrontOfText, h_rel: *h_rel, h_align: *h_align, x: *x, ..Default::default() };
d.insert_object(&Pos::body(block, 0), rect_shape(20.0 + 2.0 * i as f32, 10.0, f), &Default::default()).unwrap();
}
}
// Left edge of shape `i` on an odd and on an even page (page coordinates).
let want = |i: usize, even: bool| {
let w = 20.0 + 2.0 * i as f32;
match (i, even) {
(0, false) => 576.0 - w,
(0, true) => 36.0,
(1, false) => 576.0 + (36.0 - w) / 2.0,
(1, true) => (36.0 - w) / 2.0,
(2, false) => (118.0 - w) / 2.0,
(2, true) => 494.0 + (118.0 - w) / 2.0,
(3, false) => 0.0,
(3, true) => 612.0 - w,
(4, _) => 10.0,
(5, false) => 118.0 - w,
_ => 36.0 - w,
}
};
let l = lay(&d);
assert_eq!(l.pages.len(), 2);
for (pi, page) in l.pages.iter().enumerate() {
let r = shapes(&page.items);
assert_eq!(r.len(), floats.len(), "page {pi}: {r:?}");
for (i, f) in floats.iter().enumerate() {
let w = 20.0 + 2.0 * i as f32;
let got = r.iter().find(|r| (r.w - w).abs() < 0.01).unwrap();
let x = want(i, page.number % 2 == 0);
assert!((got.x - x).abs() < 0.01, "page {} shape {i} ({:?}): at {}, want {x}", page.number, f, got.x);
}
}
// Without mirrored margins the margins stay put, but inside and outside still follow the
// page (ECMA-376 §20.4.3.4): the inside of an even page is its right side.
d.settings.mirror_margins = false;
let l = lay(&d);
let r = shapes(&l.pages[1].items);
let at = |w: f32| r.iter().find(|r| (r.w - w).abs() < 0.01).unwrap().x;
assert!((at(20.0) - 118.0).abs() < 0.01, "outside of the margins on page 2: {}", at(20.0));
assert!((at(26.0) - (612.0 - 26.0)).abs() < 0.01, "inside of page 2: {}", at(26.0));
assert!((at(28.0) - 10.0).abs() < 0.01, "{}", at(28.0));
}
#[test]
fn outside_floating_tables_swap_sides_on_even_pages() {
// #1181, for a floating table (`w:tblpXSpec="outside"`): on page 2 of a book the outside is
// the left. One positioned from the page edge stays there on both pages.
use wordcraft_doc::para::{Anchor, FloatAlign};
use wordcraft_doc::props::TableFloat;
let table_x = |page_two: bool, float: TableFloat| {
let mut d = Document::from_text("First\nSecond\nAfter");
d.last_section.margin_left = 108.0;
d.last_section.margin_right = 36.0;
d.last_section.gutter = 10.0;
d.settings.mirror_margins = true;
if let Some(Block::Para(p)) = d.body.get_mut(1).map(Arc::make_mut) {
p.props.page_break_before = Some(page_two);
}
let mut t = wordcraft_doc::Table::new(1, 1, 100.0);
t.props.float = Some(float);
d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(t)).unwrap();
let l = lay(&d);
let page = &l.pages[page_two as usize];
let x = page.items.iter().find_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, x, .. } if path.0.len() > 1 => Some(*x),
_ => None,
});
x.unwrap()
};
let outside = TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, h_align: Some(FloatAlign::Outside), ..Default::default() };
let (odd, even) = (table_x(false, outside), table_x(true, outside));
assert!(odd > 576.0 - 100.0 - 0.01 && odd < 576.0, "odd page: at the right margin, text at {odd}");
assert!(even > 36.0 - 0.01 && even < 36.0 + 20.0, "even page: at the left margin, text at {even}");
let from_page = TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Paragraph, x: 200.0, ..Default::default() };
let (odd, even) = (table_x(false, from_page), table_x(true, from_page));
assert!((odd - even).abs() < 0.01, "same place from the page edge: {odd} and {even}");
}
#[test]
fn a_tall_footer_pushes_the_body_up() {
// #453: an eight-line footer reaches far above the 72pt bottom margin.
+12 -3
View File
@@ -246,6 +246,8 @@ pub enum Dialog {
left: f32,
right: f32,
landscape: bool,
/// Mirrored margins (the whole document's): left and right become inside and outside.
mirror: bool,
/// Document grid: kind (0 none, 1 lines, 2 lines and characters, 3 snap to characters),
/// lines per page and characters per line; `grid_was` as opened (applied only if changed).
grid: (u8, u32, u32),
@@ -1175,6 +1177,7 @@ impl Dialog {
left: sp.margin_left / crate::options::unit().pt_per_unit(),
right: sp.margin_right / crate::options::unit().pt_per_unit(),
landscape: sp.landscape,
mirror: app.session.doc.settings.mirror_margins,
grid,
grid_was: grid,
rtl: sp.rtl,
@@ -2118,7 +2121,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel
}
Dialog::PageSetup { top, bottom, left, right, landscape, rtl, grid, grid_was, paper, paper_was, paper_custom, message } => {
Dialog::PageSetup { top, bottom, left, right, landscape, mirror, rtl, grid, grid_was, paper, paper_was, paper_custom, message } => {
ui.label(egui::RichText::new(tl!("Margins")).font(semibold(12.5)));
egui::Grid::new("ps").num_columns(4).show(ui, |ui| {
ui.label(tl!("Top:"));
@@ -2128,9 +2131,9 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
egui::DragValue::new(bottom).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2),
);
ui.end_row();
ui.label(tl!("Left:"));
ui.label(if *mirror { tl!("Inside:") } else { tl!("Left:") });
ui.add(egui::DragValue::new(left).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2));
ui.label(tl!("Right:"));
ui.label(if *mirror { tl!("Outside:") } else { tl!("Right:") });
ui.add(egui::DragValue::new(right).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2));
ui.end_row();
});
@@ -2139,6 +2142,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.radio_value(landscape, false, tl!("Portrait"));
ui.radio_value(landscape, true, tl!("Landscape"));
});
ui.label(egui::RichText::new(tl!("Pages")).font(semibold(12.5)));
ui.checkbox(mirror, tl!("Mirror margins"));
page_paper_group(ui, paper, landscape, paper_custom);
ui.label(egui::RichText::new(tl!("Document Grid")).font(semibold(12.5)));
ui.horizontal_wrapped(|ui| {
@@ -2168,6 +2173,10 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let result = page_layout_commands(app, (*paper, *paper_was), *landscape, (*top, *bottom, *left, *right))
.map_err(|e| tl!(e).to_string())
.and_then(|mut commands| {
// Mirror margins go with the margins (#1181).
if let Some((_, v)) = commands.iter_mut().rev().find(|(id, _)| *id == "layout.margins") {
v["mirror"] = json!(*mirror);
}
if grid != grid_was {
let kind = ["none", "lines", "linesAndChars", "snapToChars"].get(grid.0 as usize).copied().unwrap_or("none");
let mut v = json!({"type": kind});
+3
View File
@@ -2566,3 +2566,6 @@
# Backstage › Recover (#586)
Recover Възстановяване
# Page Setup › Mirror margins (#1239)
Mirror margins Огледални полета
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -2570,3 +2570,6 @@ preview Title Ukázka
# Backstage › Recover (#586)
Recover Obnovit
# Page Setup › Mirror margins (#1239)
Mirror margins Zrcadlové okraje
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -2178,3 +2178,6 @@
# Backstage › Recover (#586)
Recover Wiederherstellen
# Page Setup › Mirror margins (#1239)
Mirror margins Gegenüberliegende Seiten
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -2545,3 +2545,6 @@ preview Title Título
# Backstage › Recover (#586)
Recover Recuperar
# Page Setup › Mirror margins (#1239)
Mirror margins Márgenes simétricos
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -2213,3 +2213,6 @@
# Backstage › Recover (#586)
Recover Taasta
# Page Setup › Mirror margins (#1239)
Mirror margins Peegelveerised
Can't render this file because it contains an unexpected character in line 463 and column 13.
+3
View File
@@ -2186,3 +2186,6 @@
# Backstage › Recover (#586)
Recover Récupérer
# Page Setup › Mirror margins (#1239)
Mirror margins Marges en vis-à-vis
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -2574,3 +2574,6 @@
# Backstage › Recover (#586)
Recover Helyreállítás
# Page Setup › Mirror margins (#1239)
Mirror margins Tükrözött margók
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -2587,3 +2587,6 @@ preview Title Esempio
Use "an" before a vowel sound Usa "an" davanti a un suono vocalico
➕ New comment ➕ Nuovo commento
📂 Browse… 📂 Sfoglia…
# Page Setup › Mirror margins (#1239)
Mirror margins Margini speculari
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -2179,3 +2179,6 @@
# Backstage › Recover (#586)
Recover 回復
# Page Setup › Mirror margins (#1239)
Mirror margins 見開きページ
Can't render this file because it contains an unexpected character in line 571 and column 13.
+3
View File
@@ -2568,3 +2568,6 @@ preview Title Tytuł
# Backstage › Recover (#586)
Recover Odzyskaj
# Page Setup › Mirror margins (#1239)
Mirror margins Marginesy lustrzane
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -2191,3 +2191,6 @@
# Backstage › Recover (#586)
Recover Recuperar
# Page Setup › Mirror margins (#1239)
Mirror margins Margens espelho
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -2576,3 +2576,6 @@ preview Title Пример
# Backstage › Recover (#586)
Recover Восстановить
# Page Setup › Mirror margins (#1239)
Mirror margins Зеркальные поля
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -2189,3 +2189,6 @@
# Backstage › Recover (#586)
Recover Oporavi
# Page Setup › Mirror margins (#1239)
Mirror margins Preslikane margine
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -2193,3 +2193,6 @@
# Backstage › Recover (#586)
Recover Опорави
# Page Setup › Mirror margins (#1239)
Mirror margins Пресликане маргине
Can't render this file because it contains an unexpected character in line 11 and column 25.
+3
View File
@@ -2575,3 +2575,6 @@ preview Title Örnek
# Backstage › Recover (#586)
Recover Kurtar
# Page Setup › Mirror margins (#1239)
Mirror margins Yansıtılmış kenar boşlukları
Can't render this file because it contains an unexpected character in line 444 and column 13.
+3
View File
@@ -2191,3 +2191,6 @@
# Backstage › Recover (#586)
Recover Відновити
# Page Setup › Mirror margins (#1239)
Mirror margins Дзеркальні поля
Can't render this file because it contains an unexpected character in line 6 and column 31.
+3
View File
@@ -2606,3 +2606,6 @@
Delete Bookmark Xóa dấu trang
Hidden bookmarks Dấu trang ẩn
Sort by: Sắp xếp theo:
# Page Setup › Mirror margins (#1239)
Mirror margins Lề đối xứng
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -2191,3 +2191,6 @@
# Backstage › Recover (#586)
Recover 恢复
# Page Setup › Mirror margins (#1239)
Mirror margins 对称页边距
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -2180,3 +2180,6 @@
# Backstage › Recover (#586)
Recover 復原
# Page Setup › Mirror margins (#1239)
Mirror margins 左右對稱邊界
Can't render this file because it contains an unexpected character in line 572 and column 13.
+3 -1
View File
@@ -1354,7 +1354,9 @@ fn margins_items(ui: &mut Ui, app: &mut WordApp) {
("Mirrored", "mirrored"),
("Office 2003 Default", "office2003"),
] {
mi_check(ui, app, l, current == Some(k), "layout.margins", json!({"preset": k}));
// Mirrored margins come from the document as well as from the preset (#1181).
let on = current == Some(k) || (k == "mirrored" && app.session.doc.settings.mirror_margins);
mi_check(ui, app, l, on, "layout.margins", json!({"preset": k}));
}
mi(ui, app, "Custom Margins…", "ui.dialog", json!({"name": "pageSetup"}));
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Layout and pagination parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (STYLEREF running headers per page, #775; previously trivial: PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: a paragraph continued into the next column or page wraps around that column's shapes, #1374; previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; 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-10-14 · **Change:** trivial (floats aligned inside/outside or placed in the inside/outside margin swap sides on even pages; page-relative floats no longer move with mirrored text, #1181; previously trivial: STYLEREF running headers per page, #775; previously trivial: PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: a paragraph continued into the next column or page wraps around that column's shapes, #1374; previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; 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,
@@ -53,7 +53,7 @@ font can't match Word's metrics.
| Page borders (from page edge or text), page colour, watermark | ✅ | | — |
| Line numbers (restart per page/section, count by) | ✅ | | — |
| Vertical page alignment | ✅ | | — |
| Gutter, mirror margins, book fold, 2 pages per sheet | 🟡 | gutter and mirror margins (even pages put the inside margin and gutter on the right, #452; page-relative floats move with the text); book fold missing | 2–3 |
| Gutter, mirror margins, book fold, 2 pages per sheet | 🟡 | gutter and mirror margins (even pages put the inside margin and gutter on the right, #452; page-relative floats stay put, and inside/outside alignment and margins follow the page side, #1181); book fold missing | 2–3 |
| Vertical text (`tbRl`) in sections and text boxes; cell text direction | ✅ | cell text direction (#245); text-box text direction (#369); vertical sections (`tbRl`/`btLr`, #491): the page's text area turned, lines top to bottom stacking right to left, ideographs, kana and Hangul upright, Latin on its side, caret and clicks through the turn. Missing: OpenType `vert` alternates (punctuation is moved, not substituted), arrow keys along vertical lines, upright pictures take turned room | 2–4 |
| Gutter, mirror margins, book fold, 2 pages per sheet | 🟡 | gutter and mirror margins; book fold missing | 2–3 |
| Document grid (`w:docGrid`, lines per page, characters per line) | 🟡 | #391, #401: line grid snaps line heights to whole pitches (text centred in them; `w:snapToGrid` off opts a paragraph out); character grid puts East Asian characters on the character pitch (`linesAndChars`) or all text (`snapToChars`), Latin text proportional; View › Gridlines draws it. Not yet: run-level `w:snapToGrid`, grid in headers/footers, paragraph spacing in grid lines, Word's exact glyph placement in a cell | 1–2 |
@@ -158,6 +158,7 @@ No overall parity estimate changes from these probes.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | Floats and floating tables aligned inside/outside, or placed in the inside/outside margin, swap sides on even pages; with mirrored margins, page-relative floats keep their place and left/right margin areas are each page's own (#1181) |
| 2026-10-13 | trivial | PAGEREF in headers and footers shows its bookmark's page (#1477) |
| 2026-10-13 | trivial | A shape or picture dragged into another column anchors there, so the text in that column wraps around it (#884) |
| 2026-10-13 | trivial | On a page with footnotes, a paragraph that ends there keeps its space after above the footnote separator; if it doesn't fit, its last line (and, with widow control, one more) goes to the next page with the note. Pages without notes are unchanged (#1116) |