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 <noreply@anthropic.com>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-10 12:18:07 -04:00
1 parent 68364f75c8
commit e53b618584
3 files changed
+173 -14

No files matched your search

+68 -14
View File
@@ -703,23 +703,14 @@ impl Reader<'_> {
fn read_vml(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Option<InlineObject> {
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()
}
}
+58
View File
@@ -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##"<w:p><w:r><w:pict><v:shape id="Box" type="#_x0000_t202" style="position:absolute;left:0;margin-left:0pt;margin-top:80pt;width:405pt;height:491.4pt;z-index:-1;mso-position-horizontal-relative:page;mso-position-vertical-relative:page"><v:textbox><w:txbxContent><w:p><w:r><w:t>WATERMARK</w:t></w:r></w:p></w:txbxContent></v:textbox><w10:wrap type="none"/></v:shape></w:pict></w:r></w:p>
<w:p><w:r><w:pict><v:rect style="position:absolute;margin-left:12pt;margin-top:-6pt;width:1in;height:36pt;z-index:3;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical-relative:top-margin-area;mso-wrap-distance-left:9pt;mso-wrap-distance-bottom:4pt"><v:textbox><w:txbxContent><w:p><w:r><w:t>square</w:t></w:r></w:p></w:txbxContent></v:textbox><w10:wrap type="square"/></v:rect></w:pict></w:r>
<w:r><w:pict><v:shape style="position:absolute;margin-left:1in;margin-top:2pt;width:72pt;height:20pt;z-index:5"><v:textbox><w:txbxContent><w:p><w:r><w:t>front</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r>
<w:r><w:pict><v:shape style="width:72pt;height:20pt"><v:textbox><w:txbxContent><w:p><w:r><w:t>inline</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>""##;
let header = format!(r#"<w:hdr {W_NS} {w10}><w:p><w:r><w:t>Header label</w:t></w:r></w:p>{boxes}</w:hdr>"#);
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"#;
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#"<w:p><w:r><w:pict><v:shape style="position:ABSOLUTE;margin-left:1e39pt;left:-1e39pt;margin-top:NaNpt;top:;width:-5pt;height:1e30in;z-index:99999999999999999999999;mso-position-horizontal:bogus;mso-wrap-distance-left:-3pt;mso-wrap-distance-top:1e9pt;;:;position"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>"#;
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));
}
+47
View File
@@ -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<String> = (1..=40).map(|i| format!("Body paragraph {i}: synthetic public test content.")).collect();
let make = |float: Option<Float>| {
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<String> {
l.pages