mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-01 22:37:10 -06:00
Merge PR #2034: Floating objects are drawn in their stacking order (relativeHeight)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = ¶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#"<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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user