From e53b618584e9d2a016d269665ea51f67f8c10187 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 18:18:07 +0200 Subject: [PATCH] Absolutely positioned VML text boxes float instead of sitting inline (#149) (#242) A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its style was read as an inline object, so a tall text box in a header made the header as tall as the box and pushed the body down the page. The reader now maps the VML style to the same float model DrawingML anchors use: left/margin-left and top/margin-top offsets, mso-position-*-relative reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-* and w10:wrap (square, tight, through, topAndBottom; none or absent is in front of the text, or behind it at a negative z-index). Shapes without position:absolute stay inline. Saving writes the boxes as DrawingML anchors with the same placement. Co-authored-by: Claude Opus 5.5 --- crates/docx/src/read/story.rs | 82 +++++++++++++++++++++++++++++------ crates/docx/tests/fixtures.rs | 58 +++++++++++++++++++++++++ crates/layout/src/tests.rs | 47 ++++++++++++++++++++ 3 files changed, 173 insertions(+), 14 deletions(-) diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index fe9f66ada1..496e244458 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -703,23 +703,14 @@ impl Reader<'_> { fn read_vml(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Option { let shape = pict.els().find(|e| e.name.starts_with("v:") && e.local() != "shapetype")?; let style = shape.attr("style").unwrap_or(""); - let mut w = 0.0; - let mut h = 0.0; - for decl in style.split(';') { - let mut kv = decl.splitn(2, ':'); - let (Some(k), Some(v)) = (kv.next(), kv.next()) else { continue }; - let v = measure(v.trim(), 1.0).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT); - match k.trim() { - "width" => w = v, - "height" => h = v, - _ => {} - } - } + let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT); + let (w, h) = (len("width"), len("height")); + let float = vml_float(shape, style); if let Some(img) = shape.find("v:imagedata") { let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?; let media = self.media_for(rels, id)?; let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string(); - return Some(InlineObject::Image { media, w, h, alt, float: Float::default(), crop: [0.0; 4] }); + return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4] }); } if let Some(t) = shape.find("w:txbxContent") && sc.story_depth < MAX_STORY_DEPTH @@ -727,7 +718,7 @@ impl Reader<'_> { let story = Some(self.read_textbox(sc, t, rels)); let fill = shape.attr("fillcolor").and_then(Rgb::parse); let stroke = shape.attr("strokecolor").and_then(Rgb::parse); - return Some(InlineObject::Shape { kind: ShapeKind::TextBox, w, h, fill, stroke, stroke_width: 0.75, float: Float::default(), story }); + return Some(InlineObject::Shape { kind: ShapeKind::TextBox, w, h, fill, stroke, stroke_width: 0.75, float, story }); } None } @@ -849,3 +840,66 @@ fn anchor_float(c: &El) -> Float { f.dist_bottom = dist("distB"); f } + +/// The value of property `key` in a VML `style` attribute (CSS declarations; the last one wins). +fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> { + style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim()) +} + +/// Placement of a VML shape (ECMA-376 Part 4, VML): `position:absolute` in its style makes it +/// float, offset by `left`/`margin-left` and `top`/`margin-top` from the area named by +/// `mso-position-horizontal-relative` / `mso-position-vertical-relative` (unless aligned with +/// `mso-position-horizontal` / `mso-position-vertical`), wrapped as its `w10:wrap` says. Without +/// wrapping (`none`, or no `w10:wrap`) it's in front of the text, or behind it at a negative +/// `z-index`. Anything else stays inline. +fn vml_float(shape: &El, style: &str) -> Float { + let get = |k: &str| vml_style(style, k); + if !get("position").is_some_and(|p| p.eq_ignore_ascii_case("absolute")) { + return Float::default(); + } + let off = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0); + let max = crate::units::MAX_LEN_PT; + let behind = get("z-index").and_then(int).is_some_and(|z| z < 0); + let wrap = match shape.child("w10:wrap").and_then(|w| w.attr("type")).unwrap_or("none") { + "square" => Wrap::Square, + "tight" => Wrap::Tight, + "through" => Wrap::Through, + "topAndBottom" => Wrap::TopAndBottom, + _ if behind => Wrap::BehindText, + _ => Wrap::InFrontOfText, + }; + let h_rel = match get("mso-position-horizontal-relative").unwrap_or("text") { + "page" => Anchor::Page, + "margin" => Anchor::Margin, + "char" => Anchor::Character, + "left-margin-area" => Anchor::LeftMargin, + "right-margin-area" => Anchor::RightMargin, + "inner-margin-area" => Anchor::InsideMargin, + "outer-margin-area" => Anchor::OutsideMargin, + _ => Anchor::Column, + }; + let v_rel = match get("mso-position-vertical-relative").unwrap_or("text") { + "page" => Anchor::Page, + "margin" => Anchor::Margin, + "line" => Anchor::Line, + "top-margin-area" => Anchor::TopMargin, + "bottom-margin-area" => Anchor::BottomMargin, + "inner-margin-area" => Anchor::InsideMargin, + "outer-margin-area" => Anchor::OutsideMargin, + _ => Anchor::Paragraph, + }; + let dist = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 1584.0); + Float { + wrap, + h_rel, + v_rel, + x: (off("left") + off("margin-left")).clamp(-max, max), + y: (off("top") + off("margin-top")).clamp(-max, max), + h_align: get("mso-position-horizontal").and_then(FloatAlign::from_ooxml), + v_align: get("mso-position-vertical").and_then(FloatAlign::from_ooxml), + dist: dist("mso-wrap-distance-left").max(dist("mso-wrap-distance-right")), + dist_top: dist("mso-wrap-distance-top"), + dist_bottom: dist("mso-wrap-distance-bottom"), + ..Float::default() + } +} diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 62805991a3..00ac2c8f3f 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -588,3 +588,61 @@ fn bare_break_directly_in_paragraph() { let d2 = wordcraft_docx::read(&out).unwrap(); assert_eq!(paras(&d2)[0].text, "First\nSecond\n\u{C}Third"); } + +/// Issue #149: an absolutely positioned VML text box in a header floats where its style puts it +/// (relative to the page here, behind the text at a negative z-index, no wrapping) instead of +/// sitting inline in the header's flow, and it keeps that placement when saved and read back. +#[test] +fn absolute_vml_text_box_in_header_floats() { + let w10 = r#"xmlns:w10="urn:schemas-microsoft-com:office:word""#; + let boxes = r##"WATERMARK +square +front +inline""##; + let header = format!(r#"Header label{boxes}"#); + let body = r#"Body"#; + let bytes = docx(body, &[("rIdH", "header", "header1.xml")], &[("word/header1.xml", &header)]); + let check = |d: &Document| { + let hid = d.last_section.headers.default.unwrap(); + let blocks = &d.parts.get(&hid).unwrap().blocks; + let shapes: Vec<_> = blocks + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| &p.objects) + .filter_map(|o| if let InlineObject::Shape { kind, w, h, float, story, .. } = o { Some((*kind, *w, *h, *float, *story)) } else { None }) + .collect(); + let [(k0, w0, h0, f0, s0), (_, w1, h1, f1, _), (_, _, _, f2, _), (_, _, _, f3, _)] = shapes.as_slice() else { panic!("{shapes:?}") }; + assert_eq!(*k0, ShapeKind::TextBox); + assert!((w0 - 405.0).abs() < 0.01 && (h0 - 491.4).abs() < 0.01, "{w0} x {h0}"); + assert_eq!((f0.wrap, f0.h_rel, f0.v_rel, f0.h_align, f0.v_align), (Wrap::BehindText, Anchor::Page, Anchor::Page, None, None)); + assert!(f0.x.abs() < 0.01 && (f0.y - 80.0).abs() < 0.01, "offset {} {}", f0.x, f0.y); + let text = d.parts.get(&s0.unwrap()).unwrap().blocks[0].as_para().unwrap().text.clone(); + assert_eq!(text, "WATERMARK"); + assert_eq!((*w1, *h1), (72.0, 36.0)); + assert_eq!( + (f1.wrap, f1.h_rel, f1.h_align, f1.v_rel, f1.v_align), + (Wrap::Square, Anchor::Margin, Some(FloatAlign::Center), Anchor::TopMargin, None) + ); + assert!((f1.y + 6.0).abs() < 0.01 && (f1.dist - 9.0).abs() < 0.01 && (f1.dist_bottom - 4.0).abs() < 0.01, "{f1:?}"); + // No wrap element: in front of the text, relative to the column and paragraph. + assert_eq!((f2.wrap, f2.h_rel, f2.v_rel), (Wrap::InFrontOfText, Anchor::Column, Anchor::Paragraph)); + assert!((f2.x - 72.0).abs() < 0.01 && (f2.y - 2.0).abs() < 0.01, "{f2:?}"); + // Not absolutely positioned: still inline. + assert_eq!(f3.wrap, Wrap::Inline); + }; + let d = wordcraft_docx::read(&bytes).unwrap(); + check(&d); + // Saved (as DrawingML anchors) and read back, the boxes keep their placement. + check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap()); +} + +#[test] +fn hostile_vml_style_values_stay_finite() { + let body = r#""#; + let d = read_body(body); + let p = paras(&d); + let Some(InlineObject::Shape { w, h, float, .. }) = p[0].objects.first() else { panic!("{:?}", p[0].objects) }; + assert_eq!(float.wrap, Wrap::InFrontOfText); + assert!([*w, *h, float.x, float.y, float.dist, float.dist_top].iter().all(|v| v.is_finite()), "{w} {h} {float:?}"); + assert_eq!((float.h_align, float.dist, float.dist_top), (None, 0.0, 1584.0)); +} diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index ffae8adc56..1ad7d35bf9 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1889,6 +1889,53 @@ fn page_relative_header_float_wraps_where_it_is_drawn() { assert!((wrapped - in_front).abs() < 0.5, "body starts at {wrapped} with wrapping, {in_front} without"); } +/// Issue #149: a tall text box floating behind the text in the header (placed relative to the +/// page, no wrapping) neither makes the header taller nor pushes the body down: the body starts +/// at the top margin and paginates exactly as without the box. +#[test] +fn floating_header_text_box_leaves_the_body_at_the_top_margin() { + use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap}; + let body: Vec = (1..=40).map(|i| format!("Body paragraph {i}: synthetic public test content.")).collect(); + let make = |float: Option| { + let mut d = Document::from_text(&body.join("\n")); + d.last_section.header = 36.0; + // A label, then the paragraph the box is anchored to (empty in the control). + let tight = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() }; + let mut label = wordcraft_doc::Paragraph::with_text("Header label", Default::default()); + label.props = tight.clone(); + let mut anchor = wordcraft_doc::Paragraph { props: tight, ..Default::default() }; + if let Some(float) = float { + let text = wordcraft_doc::Paragraph::with_text("WATERMARK", Default::default()); + let story = d.add_part(wordcraft_doc::PartKind::TextBox, vec![wordcraft_doc::para_block(text)]); + let tb = InlineObject::Shape { + kind: ShapeKind::TextBox, + w: 405.0, + h: 491.4, + fill: None, + stroke: None, + stroke_width: 0.75, + float, + story: Some(story), + }; + anchor.insert_object(0, tb, &Default::default()).unwrap(); + } + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(label), wordcraft_doc::para_block(anchor)]); + d.last_section.headers.default = Some(id); + let l = lay(&d); + (l.pages.len(), first_line_top(&l.pages[0].items)) + }; + let (control_pages, control_top) = make(None); + let behind = Float { wrap: Wrap::BehindText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 0.0, y: 80.0, ..Default::default() }; + let (pages, top) = make(Some(behind)); + assert!(control_pages > 1, "the control needs more than one page: {control_pages}"); + assert!((control_top - 72.0).abs() < 0.5, "control body starts at the top margin: {control_top}"); + assert!((top - control_top).abs() < 0.5, "body starts at {top} with the floating box, {control_top} without"); + assert_eq!(pages, control_pages); + // The same box inline does take room in the header, which is what the reader used to make of it. + let (inline_pages, inline_top) = make(Some(Float::default())); + assert!(inline_top > 400.0 && inline_pages > control_pages, "inline box: body at {inline_top}, {inline_pages} pages"); +} + /// List labels drawn, in page order (each paragraph's first line). fn labels(l: &DocLayout) -> Vec { l.pages