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