Merge PR #2244: fix(docx, doc): support VML wrap side and case-insensitive float anchors

crates/docx/tests/fixtures.rs: both sides' appended tests kept.
This commit is contained in:
aiongg
2026-10-22 01:21:19 +00:00
3 changed files with 66 additions and 28 deletions
+11 -6
View File
@@ -77,12 +77,17 @@ pub enum WrapText {
impl WrapText {
/// From the OOXML value (`bothSides`, `left`, `right`, `largest`).
pub fn from_ooxml(v: &str) -> Option<WrapText> {
match v {
"bothSides" => Some(WrapText::BothSides),
"left" => Some(WrapText::Left),
"right" => Some(WrapText::Right),
"largest" => Some(WrapText::Largest),
_ => None,
let v = v.trim();
if v.eq_ignore_ascii_case("bothSides") || v.eq_ignore_ascii_case("both") {
Some(WrapText::BothSides)
} else if v.eq_ignore_ascii_case("left") {
Some(WrapText::Left)
} else if v.eq_ignore_ascii_case("right") {
Some(WrapText::Right)
} else if v.eq_ignore_ascii_case("largest") {
Some(WrapText::Largest)
} else {
None
}
}
+31 -22
View File
@@ -1635,7 +1635,10 @@ impl Reader<'_> {
let type_id = shape.attr("type").map(|t| t.trim().trim_start_matches('#'));
let shapetype = type_id.and_then(|id| pict.els().find(|e| e.name == "v:shapetype" && e.attr("id") == Some(id)));
let attr = |name: &str| shape.attr(name).or_else(|| shapetype.and_then(|t| t.attr(name)));
let on = |name: &str| attr(name).is_none_or(|v| !matches!(v, "f" | "false" | "0"));
let on = |name: &str| attr(name).is_none_or(|v| {
let t = v.trim();
!t.eq_ignore_ascii_case("f") && !t.eq_ignore_ascii_case("false") && t != "0"
});
let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
let fill = vml_fill(shape, shapetype);
@@ -2137,37 +2140,43 @@ fn vml_float(shape: &El, style: &str, in_hf: bool) -> Float {
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,
let wrap_el = shape.child("w10:wrap");
let wrap_type = wrap_el.and_then(|w| w.attr("type")).unwrap_or("none");
let wrap = match wrap_type {
t if t.eq_ignore_ascii_case("square") => Wrap::Square,
t if t.eq_ignore_ascii_case("tight") => Wrap::Tight,
t if t.eq_ignore_ascii_case("through") => Wrap::Through,
t if t.eq_ignore_ascii_case("topAndBottom") || t.eq_ignore_ascii_case("top-and-bottom") => 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,
let wrap_text = wrap_el.and_then(|w| w.attr("side")).and_then(wordcraft_doc::para::WrapText::from_ooxml).unwrap_or_default();
let h_rel_str = get("mso-position-horizontal-relative").unwrap_or("text");
let h_rel = match h_rel_str {
s if s.eq_ignore_ascii_case("page") => Anchor::Page,
s if s.eq_ignore_ascii_case("margin") => Anchor::Margin,
s if s.eq_ignore_ascii_case("char") => Anchor::Character,
s if s.eq_ignore_ascii_case("left-margin-area") => Anchor::LeftMargin,
s if s.eq_ignore_ascii_case("right-margin-area") => Anchor::RightMargin,
s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin,
s if s.eq_ignore_ascii_case("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,
let v_rel_str = get("mso-position-vertical-relative").unwrap_or("text");
let v_rel = match v_rel_str {
s if s.eq_ignore_ascii_case("page") => Anchor::Page,
s if s.eq_ignore_ascii_case("margin") => Anchor::Margin,
s if s.eq_ignore_ascii_case("line") => Anchor::Line,
s if s.eq_ignore_ascii_case("top-margin-area") => Anchor::TopMargin,
s if s.eq_ignore_ascii_case("bottom-margin-area") => Anchor::BottomMargin,
s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin,
s if s.eq_ignore_ascii_case("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);
let mut f = Float {
wrap,
wrap_text,
h_rel,
v_rel,
x: (off("left") + off("margin-left")).clamp(-max, max),
+24
View File
@@ -3652,3 +3652,27 @@ fn shape_gradients_and_transparency_are_read_and_survive_saving() {
check(&d);
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
#[test]
fn vml_shape_wrap_side_and_anchor_case_insensitivity() {
let body = r##"<w:p>
<w:r>
<w:pict xmlns:w10="urn:schemas-microsoft-com:office:word">
<v:rect style="position:absolute;width:100pt;height:50pt;mso-position-horizontal-relative:Page;mso-position-vertical-relative:Margin" filled="False" stroked="t">
<w10:wrap type="Square" side="left"/>
</v:rect>
</w:pict>
</w:r>
</w:p>"##;
let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
let p = doc.body.iter().find_map(|b| b.as_para()).unwrap();
let shape = match p.objects.first() {
Some(InlineObject::Shape { fill, float, .. }) => (fill, float),
o => panic!("expected shape, got {o:?}"),
};
assert_eq!(*shape.0, None);
assert_eq!(shape.1.wrap, wordcraft_doc::para::Wrap::Square);
assert_eq!(shape.1.wrap_text, wordcraft_doc::para::WrapText::Left);
assert_eq!(shape.1.h_rel, wordcraft_doc::para::Anchor::Page);
assert_eq!(shape.1.v_rel, wordcraft_doc::para::Anchor::Margin);
}