Merge PR #2034: Floating objects are drawn in their stacking order (relativeHeight)

This commit is contained in:
aiongg
2026-10-22 01:19:45 +00:00
7 changed files with 101 additions and 12 deletions
+7
View File
@@ -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).
+4 -1
View File
@@ -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::<u32>().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).
+3 -1
View File
@@ -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",
+19
View File
@@ -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#"<w:r><w:drawing><wp:anchor behindDoc="0" {z}><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="127000" cy="127000"/><wp:wrapNone/><wp:docPr id="1" name="S"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
)
};
let body = format!("<w:p>{}{}{}</w:p>", shape(r#"relativeHeight="251660288""#), shape(r#"relativeHeight="251657214""#), shape(""));
let zs = |d: &Document| -> Vec<u32> {
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("<w:p/>").settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
+19 -4
View File
@@ -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<u32>) -> 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::<Vec<_>>().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("<a:custGeom>").count(), 3, "{xml}");
assert!(xml.contains(r#"<a:path w="762000" h="254000" fill="none"><a:moveTo><a:pt x="12700" y="12700"/></a:moveTo><a:lnTo>"#), "{xml}");
assert!(xml.contains(r#"<a:alpha val="50000"/>"#) && xml.contains(r#"cap="rnd""#), "{xml}");
let r = rt(&d);
let r = unstacked(rt(&d));
let objs = &paras(&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#"<wp:wrapPolygon edited="1"><wp:start x="-300" y="0"/><wp:lineTo x="21600" y="1200"/>"#), "{xml}");
assert!(xml.contains(r#"<wp:lineTo x="-300" y="0"/></wp:wrapPolygon>"#), "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<Float> = got.objects.iter().filter_map(|o| o.frame().map(|(_, _, f)| f.clone())).collect();
assert_eq!(floats[0], edited);
+17 -6
View File
@@ -3364,8 +3364,11 @@ fn place_objects(
floats: &HashMap<usize, Rect>,
depth: usize,
) -> (Vec<Placed>, Vec<Placed>) {
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<Placed>, Vec<Placed>)> = 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)
}
+32
View File
@@ -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<f32> = 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<f32> = 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<f32> = 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};