From 53296879dbb1f13ca59b418bec94b20ddede779f Mon Sep 17 00:00:00 2001 From: Daniele Mori Date: Wed, 14 Oct 2026 17:43:35 +0200 Subject: [PATCH 1/2] Floating objects are drawn in their stacking order (relativeHeight) Word stacks floating objects by wp:anchor/@relativeHeight, not by the order of their anchors in the paragraph. The reader ignored the attribute, so a panel anchored after the text boxes it frames, but lower in the stack, was drawn over them and hid their text. - wordcraft-doc: Float gets a `z` (0 = none, drawn in document order). - wordcraft-docx: it is read from relativeHeight and written back as read; an object without one gets the next order above, as before. - wordcraft-layout: place_objects collects each object's items with its order and emits them lowest first (stable, so objects without an order keep the paragraph's). Four round-trip tests compare whole floats after a save; saving now gives objects without an order one, so those tests compare without it (`unstacked`). The order itself is covered by its own round-trip test. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/para.rs | 7 +++++++ crates/docx/src/read/story.rs | 5 ++++- crates/docx/src/write/story.rs | 4 +++- crates/docx/tests/fixtures.rs | 19 +++++++++++++++++++ crates/docx/tests/roundtrip.rs | 23 +++++++++++++++++++---- crates/layout/src/lib.rs | 23 +++++++++++++++++------ crates/layout/src/tests.rs | 21 +++++++++++++++++++++ 7 files changed, 90 insertions(+), 12 deletions(-) diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 1f2638f22..dcae2cd37 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -220,6 +220,10 @@ pub struct Float { /// off, any VML shape in a header or footer): a picture watermark in a header that starts with a table. #[serde(skip_serializing_if = "is_false")] pub outside_cell: bool, + /// Stacking order among floating objects (`wp:anchor/@relativeHeight`): higher is drawn on + /// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats). + #[serde(skip_serializing_if = "is_zero_u32")] + pub z: u32, } /// Longest alternative text kept (characters): hostile files and commands are capped. @@ -259,6 +263,9 @@ fn is_zero(v: &f32) -> bool { fn is_false(v: &bool) -> bool { !*v } +fn is_zero_u32(v: &u32) -> bool { + *v == 0 +} impl Float { /// The rotation and flips, the angle normalised to `0..360` (a NaN angle is no rotation). diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index a2ef257df..f4cfbd0dc 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -1938,7 +1938,10 @@ fn nv_alt(p: Option<&El>) -> (String, AltText) { } fn anchor_float(c: &El) -> Float { - let mut f = Float { wrap: Wrap::Square, ..Float::default() }; + // `relativeHeight`: the stacking order Word draws floating objects in (higher on top), which + // need not be their order in the file. + let z = c.attr("relativeHeight").and_then(|v| v.trim().parse::().ok()).unwrap_or(0); + let mut f = Float { wrap: Wrap::Square, z, ..Float::default() }; let behind = on_off_attr(c, "behindDoc"); // Word wraps a position relative to the page's size (`wp14:pctPosVOffset`) in // `mc:AlternateContent`; its Fallback gives the same position as an offset (#743). diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 6047e7d77..22dee2072 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -1184,7 +1184,9 @@ impl Writer<'_> { } else { let d = emu(float.dist.clamp(0.0, 1584.0)); let (dt, db) = (emu(float.dist_top.clamp(0.0, 1584.0)), emu(float.dist_bottom.clamp(0.0, 1584.0))); - self.z += 1; + // The stacking order read from the file is kept; objects without one stack in + // document order above it. + self.z = if float.z > 0 { float.z } else { self.z.saturating_add(1) }; let behind = if float.wrap == Wrap::BehindText { "1" } else { "0" }; w.open( "wp:anchor", diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 64e043894..87312ca55 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1012,6 +1012,25 @@ fn anchor_alignment_reference_areas_and_distances() { assert_eq!((b.dist, b.dist_top, b.dist_bottom), (0.0, 0.0, 0.0)); } +#[test] +fn anchor_stacking_order_is_read_and_kept() { + let shape = |z: &str| { + format!( + r#"00"# + ) + }; + let body = format!("{}{}{}", shape(r#"relativeHeight="251660288""#), shape(r#"relativeHeight="251657214""#), shape("")); + let zs = |d: &Document| -> Vec { + paras(d)[0].objects.iter().filter_map(|o| if let InlineObject::Shape { float, .. } = o { Some(float.z) } else { None }).collect() + }; + let d = read_body(&body); + assert_eq!(zs(&d), [251660288, 251657214, 0]); + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + let z = zs(&again); + assert_eq!(&z[..2], &[251660288, 251657214]); + assert!(z[2] > 0, "an object without one gets an order on saving: {z:?}"); +} + #[test] fn documents_without_a_compatibility_mode_are_laid_out_as_word_2007() { assert_eq!(read_body("").settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE); diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 430713bb3..f4170a57b 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -22,6 +22,21 @@ fn paras(doc: &Document) -> Vec<&Paragraph> { doc.body.iter().filter_map(|b| b.as_para()).collect() } +/// `doc` with the stacking order of its body's floating objects cleared: saving gives objects +/// without one an order (`relativeHeight`), so tests about other properties compare without it. +fn unstacked(mut doc: Document) -> Document { + for b in &mut doc.body { + if let Block::Para(p) = std::sync::Arc::make_mut(b) { + for o in &mut p.objects { + if let Some(f) = o.float_mut() { + f.z = 0; + } + } + } + } + doc +} + fn part_text(doc: &Document, id: Option) -> String { id.and_then(|i| doc.parts.get(&i)) .map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::>().join("\n")) @@ -457,7 +472,7 @@ fn images_round_trip() { p.insert_object(end, f.clone(), &CharProps::default()).unwrap(); } d.body = vec![para_block(p)]; - let r = rt(&d); + let r = unstacked(rt(&d)); let got = paras(&r)[0]; let mut expect = vec![inline]; expect.extend(floats); @@ -1226,7 +1241,7 @@ fn groups_round_trip() { let end = p.len(); p.insert_object(end, group, &CharProps::default()).unwrap(); d.body = vec![para_block(p)]; - let r = rt(&d); + let r = unstacked(rt(&d)); let got = paras(&r); let InlineObject::Group { w, h, float: f, ch_w, ch_h, children, .. } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) }; assert_eq!((*w, *h, *ch_w, *ch_h), (240.0, 120.0, 200.0, 100.0)); @@ -1951,7 +1966,7 @@ fn ink_and_freeforms_round_trip_as_custom_geometry() { assert_eq!(xml.matches("").count(), 3, "{xml}"); assert!(xml.contains(r#""#), "{xml}"); assert!(xml.contains(r#""#) && xml.contains(r#"cap="rnd""#), "{xml}"); - let r = rt(&d); + let r = unstacked(rt(&d)); let objs = ¶s(&r)[0].objects; assert_eq!(objs.len(), 3); // Written in reverse (each inserted at the start): the triangle, the highlighter, the pen. @@ -2332,7 +2347,7 @@ fn wrap_polygons_round_trip() { std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); assert!(xml.contains(r#""#), "{xml}"); assert!(xml.contains(r#""#), "closed: {xml}"); - let r = wordcraft_docx::read(&bytes).expect("read"); + let r = unstacked(wordcraft_docx::read(&bytes).expect("read")); let got = paras(&r)[0]; let floats: Vec = got.objects.iter().filter_map(|o| o.frame().map(|(_, _, f)| f.clone())).collect(); assert_eq!(floats[0], edited); diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 1bc00495a..11e680f06 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3216,8 +3216,11 @@ fn place_objects( floats: &HashMap, depth: usize, ) -> (Vec, Vec) { - let (mut behind, mut front) = (Vec::new(), Vec::new()); - let Some(fl) = pl.lines.get(l0) else { return (behind, front) }; + // Each object's items (behind the text, in front), with its stacking order: drawn lowest + // first once all are placed, as Word stacks floating objects by `relativeHeight`, not by + // their order in the paragraph. Inline objects and objects without one keep their order. + let mut stack: Vec<(u32, Vec, Vec)> = Vec::new(); + let Some(fl) = pl.lines.get(l0) else { return (Vec::new(), Vec::new()) }; // Objects left out of the layout (hidden, or deleted in the final text) aren't placed. let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.hide_deleted); // A floating object belongs to its paragraph, not to its line: Word puts it on the page the @@ -3245,7 +3248,6 @@ fn place_objects( continue; } let pinned = at.pinned.and_then(|m| m.get(&oi)).copied().filter(|_| floating); - let marks = (behind.len(), front.len()); let rect = if let Some(r) = pinned { r } else if floating { @@ -3254,6 +3256,7 @@ fn place_objects( let cx = x + line.cl_left(k).unwrap_or(0.0); display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, c.obj_h) }; + let (mut behind, mut front) = (Vec::new(), Vec::new()); let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front }; // Floating ones are drawn here; inline ones with their line. if floating { @@ -3299,16 +3302,24 @@ fn place_objects( } // Placed on the page from a header or footer cell: drawn with the header, once. if pinned.is_some() { - let (b, f) = (behind.split_off(marks.0.min(behind.len())), front.split_off(marks.1.min(front.len()))); if let Some(pin) = ctx.pin.as_mut() && pin.seen.insert((path.to_vec(), c.start)) { - pin.behind.extend(b); - pin.front.extend(f); + pin.behind.extend(behind); + pin.front.extend(front); } + continue; } + stack.push((if floating { float.z } else { 0 }, behind, front)); } } + // Stable: equal orders (none given) keep the paragraph's order. + stack.sort_by_key(|(z, _, _)| *z); + let (mut behind, mut front) = (Vec::new(), Vec::new()); + for (_, b, f) in stack { + behind.extend(b); + front.extend(f); + } (behind, front) } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 174dde174..6e06367ee 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4022,6 +4022,27 @@ fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() { assert!(shapes(&l.pages[1].items).is_empty(), "and not again on page two"); } +/// Floating objects are drawn in their stacking order (`relativeHeight`), not in the order of +/// their anchors: a panel anchored after the text box it frames, but lower in the stack, is +/// drawn first so the box stays visible over it. Objects without an order keep theirs. +#[test] +fn floats_are_drawn_in_their_stacking_order() { + let at = |x: f32, z: u32| rect_shape(50.0, 50.0, wordcraft_doc::para::Float { z, ..page_float(x, 100.0) }); + let mut d = Document::from_text("Text"); + for (x, z) in [(100.0, 30), (200.0, 10), (300.0, 20)] { + d.insert_object(&Pos::body(0, 4), at(x, z), &Default::default()).unwrap(); + } + let xs: Vec = shapes(&lay(&d).pages[0].items).iter().map(|r| r.x).collect(); + assert_eq!(xs, [200.0, 300.0, 100.0], "lowest first"); + let mut d = Document::from_text("Text"); + for (i, x) in [100.0, 200.0].into_iter().enumerate() { + let off = 4 + i * wordcraft_doc::para::OBJ.len_utf8(); + d.insert_object(&Pos::body(0, off), at(x, 0), &Default::default()).unwrap(); + } + let xs: Vec = shapes(&lay(&d).pages[0].items).iter().map(|r| r.x).collect(); + assert_eq!(xs, [100.0, 200.0], "no order: as anchored"); +} + #[test] fn floats_in_headers_and_table_cells_are_drawn() { use wordcraft_doc::para::{Anchor, Float, Wrap}; From 1ed20b868501429f7c49795374f18b134603e359 Mon Sep 17 00:00:00 2001 From: Daniele Mori Date: Fri, 16 Oct 2026 15:19:49 +0200 Subject: [PATCH 2/2] Stacking order test: two Behind Text objects, the higher one first in the XML As reported on #2034 with a minimal document: Word keeps the object with the higher relativeHeight on top even when it comes first in the XML. Co-Authored-By: Claude Opus 5.5 --- crates/layout/src/tests.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 6e06367ee..dfd9b4576 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4041,6 +4041,17 @@ fn floats_are_drawn_in_their_stacking_order() { } let xs: Vec = shapes(&lay(&d).pages[0].items).iter().map(|r| r.x).collect(); assert_eq!(xs, [100.0, 200.0], "no order: as anchored"); + // Behind the text too: the higher one comes first in the XML and stays on top. + let behind = |x: f32, z: u32| { + rect_shape(50.0, 50.0, wordcraft_doc::para::Float { z, wrap: wordcraft_doc::para::Wrap::BehindText, ..page_float(x, 100.0) }) + }; + let mut d = Document::from_text("Text"); + for (i, (x, z)) in [(100.0, 20), (200.0, 10)].into_iter().enumerate() { + let off = 4 + i * wordcraft_doc::para::OBJ.len_utf8(); + d.insert_object(&Pos::body(0, off), behind(x, z), &Default::default()).unwrap(); + } + let xs: Vec = shapes(&lay(&d).pages[0].items).iter().map(|r| r.x).collect(); + assert_eq!(xs, [200.0, 100.0], "behind the text: lowest first"); } #[test]