diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index e004b9086..789b4e31d 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 f5d3d3d78..cc2a14533 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -1951,7 +1951,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 e0409ed5f..0b92e9b51 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 434a22b10..f9387e2fd 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1013,6 +1013,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 47804e320..b63a7de85 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -23,6 +23,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")) @@ -458,7 +473,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); @@ -1227,7 +1242,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)); @@ -1956,7 +1971,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. @@ -2337,7 +2352,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 80f6ce251..2832e694c 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3364,8 +3364,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 @@ -3393,7 +3396,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 { @@ -3402,6 +3404,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 { @@ -3447,16 +3450,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 789854212..673aaaa9a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4023,6 +4023,38 @@ 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"); + // 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] fn floats_in_headers_and_table_cells_are_drawn() { use wordcraft_doc::para::{Anchor, Float, Wrap};