diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 2a676d91a..0553d4c0f 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -12,7 +12,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; pub fn specs() -> Vec { 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); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f9b1d2966..d7d4e9ce9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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 diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index efa2f3c5d..1a6f8efed 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -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, + /// 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::().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) -> 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) -> Option { + 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, 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, 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::().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); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 53453606b..59e24ee5f 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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 { +pub(super) fn shapes(items: &[Placed]) -> Vec { items.iter().filter_map(|i| if let Placed::Shape { rect, .. } = i { Some(*rect) } else { None }).collect() } diff --git a/crates/layout/src/tests_pages.rs b/crates/layout/src/tests_pages.rs index 0869bad32..e59a5b8d8 100644 --- a/crates/layout/src/tests_pages.rs +++ b/crates/layout/src/tests_pages.rs @@ -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. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d73a252cd..e86357d78 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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}); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ce4a7100d..1cc29afa7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2566,3 +2566,6 @@ # Backstage › Recover (#586) Recover Възстановяване + +# Page Setup › Mirror margins (#1239) + Mirror margins Огледални полета diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9aaed9095..9a19ab4fb 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2570,3 +2570,6 @@ preview Title Ukázka # Backstage › Recover (#586) Recover Obnovit + +# Page Setup › Mirror margins (#1239) + Mirror margins Zrcadlové okraje diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 93e6b9e8e..c5858aa76 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2178,3 +2178,6 @@ # Backstage › Recover (#586) Recover Wiederherstellen + +# Page Setup › Mirror margins (#1239) + Mirror margins Gegenüberliegende Seiten diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 910871ec3..782862571 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2545,3 +2545,6 @@ preview Title Título # Backstage › Recover (#586) Recover Recuperar + +# Page Setup › Mirror margins (#1239) + Mirror margins Márgenes simétricos diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 4624c3f4d..a2e1c6e91 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2213,3 +2213,6 @@ # Backstage › Recover (#586) Recover Taasta + +# Page Setup › Mirror margins (#1239) + Mirror margins Peegelveerised diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2fd447dcb..238c27553 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2186,3 +2186,6 @@ # Backstage › Recover (#586) Recover Récupérer + +# Page Setup › Mirror margins (#1239) + Mirror margins Marges en vis-à-vis diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8bc2aec22..193aa9e97 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 653b537c7..7de0a2539 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 287f9e6fd..fda529a91 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2179,3 +2179,6 @@ # Backstage › Recover (#586) Recover 回復 + +# Page Setup › Mirror margins (#1239) + Mirror margins 見開きページ diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ceb54c4a6..167b8994c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2568,3 +2568,6 @@ preview Title Tytuł # Backstage › Recover (#586) Recover Odzyskaj + +# Page Setup › Mirror margins (#1239) + Mirror margins Marginesy lustrzane diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 372ca915b..8011e2ac3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2191,3 +2191,6 @@ # Backstage › Recover (#586) Recover Recuperar + +# Page Setup › Mirror margins (#1239) + Mirror margins Margens espelho diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7333adf54..223cb62da 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2576,3 +2576,6 @@ preview Title Пример # Backstage › Recover (#586) Recover Восстановить + +# Page Setup › Mirror margins (#1239) + Mirror margins Зеркальные поля diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 9944d8cc8..e78bcc82e 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2189,3 +2189,6 @@ # Backstage › Recover (#586) Recover Oporavi + +# Page Setup › Mirror margins (#1239) + Mirror margins Preslikane margine diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index db5618148..06d11d5a4 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2193,3 +2193,6 @@ # Backstage › Recover (#586) Recover Опорави + +# Page Setup › Mirror margins (#1239) + Mirror margins Пресликане маргине diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 18fb2c010..7124962c4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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ı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4cc6dc3c6..e97733d8a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2191,3 +2191,6 @@ # Backstage › Recover (#586) Recover Відновити + +# Page Setup › Mirror margins (#1239) + Mirror margins Дзеркальні поля diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 68e05e217..d2a8f345a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d41d57836..0e45b48e7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2191,3 +2191,6 @@ # Backstage › Recover (#586) Recover 恢复 + +# Page Setup › Mirror margins (#1239) + Mirror margins 对称页边距 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0141e7fad..eb7c728c9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2180,3 +2180,6 @@ # Backstage › Recover (#586) Recover 復原 + +# Page Setup › Mirror margins (#1239) + Mirror margins 左右對稱邊界 diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ab2219a24..047f5c687 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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"})); } diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 29eb241e2..82b41985b 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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) |