Files
wordcraft/crates/docx/tests/text_boxes.rs
Hamza Naveed c2b97f1180 DOCX: read every VML shape in a w:pict, and a v:group's members (#1662)
* DOCX: read every VML shape in a `w:pict`, and a `v:group`'s members (#1643)

`read_vml` took `pict.els().find(..)`, the first `v:*` child that is not a
`v:shapetype`, and read only that one. So a `w:pict` holding several shapes
kept the first and dropped the rest, and a `v:group` was read as a single
shape -- or, with no text box in it, as nothing at all -- losing every member.
Both are gone after a save, since saving writes the model back as DrawingML.

Read all of them instead:

- `read_vml_all` walks every `v:*` child of a `w:pict` and emits one object
  each; `read_vml` keeps returning the first for the two places that show a
  single drawing (an OLE object's picture, a `mc:Fallback` preview).
- a `v:group` becomes an `InlineObject::Group`, its members mapped from the
  space `coordsize`/`coordorigin` give it onto the group's own box, nested
  groups flattened into it -- what `group_members` already does for a
  DrawingML `wpg:wgp`, so the model, the renderer and the writer need no
  change. Nesting is bounded at `MAX_VML_GROUP_DEPTH`, members at
  `MAX_GROUP_CHILDREN`.
- `read_vml_shape` is the old per-shape body, taking the group's scale so a
  member's `width`/`height` (and a `v:line`'s `from`/`to`) come through in
  the group's coordinates.

`boxes()` in the text-box tests now asks for `text_boxes()` rather than
`text_box()`, which only reports an object that is itself a box and so could
not see one inside a group; its assertions are unchanged.

* DOCX: cover a `v:group` holding several text boxes (#1643)

@KNUFFIXX reported the same loss from the other end: an inline `v:group` with
two `_x0000_t202` boxes side by side came through a convert as box A only, in
both a tall-B and a too-short-B variant, against Word reading both.

A text box is the one member a group used to keep -- `vml_textbox` recurses to
the first box that has content -- so this case looked half-working rather than
broken, and it is worth pinning separately from the shape cases. On `main` the
group collapses to a single `Shape { kind: TextBox, w: 400, h: 100 }` wearing
the group's own size and holding box A's story; here both boxes read, keep
their own stories and positions, and survive a save.

Built in code from the reported fixture rather than committing the .docx.

* DOCX: cover the shapetype lookup for a group's member (#1643)

Word writes each `v:shapetype` once, at its first use, as a child of the
`w:pict` -- so a group's member refers to one that is its parent's sibling,
not its own. Reading all shapes means `read_vml_shape` is now called for a
member as well as for a top-level shape, and both need the picture to find it,
which is why `pict` is threaded through the group walk.

Two tests: a member with no geometry and no colours of its own, taking its
diamond from the shapetype's `o:spt` and its fill from its `fillcolor`, and the
same for a shape outside a group, which is the case the lookup was written for.
2026-10-18 04:23:34 -04:00

199 lines
12 KiB
Rust

//! Text boxes' text direction, alignment and links (`wps:bodyPr/@vert`, `@anchor`;
//! `wps:txbx/@id`, `wps:linkedTxbx`). Fixtures are hand-written from ECMA-376 Part 1 §20.4
//! (DrawingML WordprocessingML drawing) and §21.1.2.1.1 (`bodyPr`).
use std::io::{Read, Write};
use wordcraft_doc::para::ShapeKind;
use wordcraft_doc::props::{TextVert, VAlign};
use wordcraft_doc::{Document, InlineObject, PartKind, TextBody, para_block};
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
/// The text boxes' stories in body order, a group's members among them
/// (`text_boxes`, not `text_box`, which only sees an object that is itself a box).
fn boxes(d: &Document) -> Vec<u32> {
let mut out = Vec::new();
d.objects_in_reading_order(&mut |o| out.extend(o.text_boxes()));
out.retain(|id| d.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox));
out
}
fn text_box(d: &mut Document, text: &str, body: TextBody) -> InlineObject {
let id = d.add_part(PartKind::TextBox, vec![para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))]);
d.parts.get_mut(&id).unwrap().body = body;
InlineObject::Shape {
kind: ShapeKind::TextBox,
w: 144.0,
h: 72.0,
fill: None,
stroke: None,
stroke_width: 0.75,
float: Default::default(),
story: Some(id),
effects: Default::default(),
freeform: None,
extra: Default::default(),
}
}
#[test]
fn direction_alignment_and_links_round_trip() {
let mut d = Document::from_text("a\nb\nc");
let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, ..Default::default() });
let b = text_box(&mut d, "", TextBody::default());
let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, ..Default::default() });
for (i, o) in [a, b, c].into_iter().enumerate() {
d.insert_object(&wordcraft_doc::Pos::body(i, 1), o, &Default::default()).unwrap();
}
let ids = boxes(&d);
d.parts.get_mut(&ids[0]).unwrap().body.next = Some(ids[1]);
d.parts.get_mut(&ids[1]).unwrap().body.next = Some(ids[2]);
let bytes = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&bytes);
assert!(xml.contains(r#"<wps:txbx id="1">"#), "{xml}");
assert!(xml.contains(r#"<wps:linkedTxbx id="1" seq="1"/>"#) && xml.contains(r#"<wps:linkedTxbx id="1" seq="2"/>"#));
assert!(xml.contains(r#"<wps:bodyPr vert="vert" anchor="ctr"/>"#) && xml.contains(r#"<wps:bodyPr vert="wordArtVert" anchor="b"/>"#));
let r = wordcraft_docx::read(&bytes).unwrap();
let got = boxes(&r);
assert_eq!(r.text_box_chains(), vec![got.clone()]);
assert_eq!(r.plain_text(wordcraft_doc::StoryRef::Part(got[0])), "Flows on");
let body = |id: u32| r.parts.get(&id).unwrap().body;
assert_eq!((body(got[0]).vert, body(got[0]).anchor), (TextVert::Vert, VAlign::Center));
assert_eq!((body(got[2]).vert, body(got[2]).anchor), (TextVert::Stacked, VAlign::Bottom));
}
/// A paragraph with an inline text box whose `wps:wsp` children are `inner`.
fn wsp(inner: &str) -> String {
format!(
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1828800" cy="914400"/><wp:docPr id="1" name="Box"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:cNvSpPr txBox="1"/><wps:spPr><a:prstGeom prst="rect"/></wps:spPr>{inner}</wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#
)
}
#[test]
fn hostile_links_read_without_chains_running_wild() {
let head = wsp(
r#"<wps:txbx id="7"><w:txbxContent><w:p><w:r><w:t>Text</w:t></w:r></w:p></w:txbxContent></wps:txbx><wps:bodyPr vert="eaVert" anchor="dist"/>"#,
);
// A repeated seq, a link to a chain with no first box, a seq that isn't a number.
let body = [
head,
wsp(r#"<wps:linkedTxbx id="7" seq="1"/><wps:bodyPr/>"#),
wsp(r#"<wps:linkedTxbx id="7" seq="1"/><wps:bodyPr/>"#),
wsp(r#"<wps:linkedTxbx id="9" seq="1"/><wps:bodyPr/>"#),
wsp(r#"<wps:linkedTxbx id="7" seq="-3"/><wps:bodyPr/>"#),
]
.concat();
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><w:body>{body}</w:body></w:document>"#
);
let r = wordcraft_docx::read(&zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])).unwrap();
let got = boxes(&r);
assert_eq!(got.len(), 4, "every box with a story is read (a bad seq links nothing)");
assert_eq!(r.text_box_chains(), vec![vec![got[0], got[1]]], "one chain: the first box and the first seq 1");
let b = r.parts.get(&got[0]).unwrap().body;
assert_eq!((b.vert, b.anchor), (TextVert::Vert, VAlign::Center));
}
#[test]
fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
// ECMA-376 Part 4 §19.1.2.22: `inset="l,t,r,b"`, each one left out keeping the default
// (0.1in, 0.05in); `mso-fit-shape-to-text` resizes the shape to its text; `v-text-anchor`
// places the text between the box's top and bottom.
let content = r#"<w:txbxContent><w:p><w:r><w:t>Boxed</w:t></w:r></w:p></w:txbxContent>"#;
let shape = |style: &str, textbox: &str| {
format!(
r#"<w:p><w:r><w:pict><v:shape style="width:100pt;height:40pt;{style}"><v:textbox {textbox}>{content}</v:textbox></v:shape></w:pict></w:r></w:p>"#
)
};
let body = [
shape("", r#"inset="0,0,0,0""#),
shape("v-text-anchor:middle", r#"inset="1mm,,2pt" style="mso-fit-shape-to-text:T""#),
shape("", ""),
// A bare number is in EMUs; one that doesn't parse keeps the default; negative is 0;
// too large is the most a margin may be.
shape("v-text-anchor:bottom-baseline", r#"inset="12700,bogus,-5pt,99in""#),
// `o:insetmode="auto"` leaves the margins to the application.
shape("", r#"o:insetmode="auto" inset="0,0,0,0""#),
// In a group, a member's margins go with that member's text, not an empty box's before it.
format!(r#"<w:p><w:r><w:pict><v:group style="width:100pt;height:40pt"><v:shape><v:textbox inset="9pt,9pt,9pt,9pt"/></v:shape><v:shape><v:textbox inset="0,0,0,0">{content}</v:textbox></v:shape></v:group></w:pict></w:r></w:p>"#),
]
.concat();
let doc = format!(
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><w:body>{body}</w:body></w:document>"#
);
let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
let bodies: Vec<TextBody> = boxes(&d).iter().map(|id| d.parts[id].body).collect();
assert_eq!(bodies.len(), 6);
assert_eq!(bodies[0].inset, Some([0, 0, 0, 0]));
assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center));
assert_eq!(bodies[2], TextBody::default());
assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom));
assert_eq!(bodies[4].inset, None);
assert_eq!(bodies[5].inset, Some([0, 0, 0, 0]));
// Saved and read again, they stay.
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
let again: Vec<TextBody> = boxes(&back).iter().map(|id| back.parts[id].body).collect();
assert_eq!(again, bodies);
}
#[test]
fn hidden_drawings_stay_hidden() {
// VML `visibility:hidden` (floating or inline) and DrawingML `wp:docPr/@hidden` (also a group
// member's `wps:cNvPr/@hidden`) mark a drawing hidden; it is kept, and saved hidden.
let vml = r#"<w:p><w:r><w:pict><v:shape style="position:absolute;width:50pt;height:50pt;visibility:hidden"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>"#;
let shown = r#"<w:p><w:r><w:pict><v:shape style="position:absolute;width:50pt;height:50pt;visibility:visible"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>"#;
let inline_vml = r#"<w:p><w:r><w:pict><v:rect style="width:20pt;height:20pt;visibility:hidden"/></w:pict></w:r></w:p>"#;
let dml = r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="635000" cy="635000"/><wp:docPr id="7" name="Box" hidden="1"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr><wps:txbx><w:txbxContent><w:p/></w:txbxContent></wps:txbx><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#;
let member = |id: u32, hidden: &str| {
format!(
r#"<wps:wsp><wps:cNvPr id="{id}" name="M{id}"{hidden}/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="635000" cy="635000"/></a:xfrm><a:prstGeom prst="rect"/></wps:spPr><wps:bodyPr/></wps:wsp>"#
)
};
let group = format!(
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1270000" cy="635000"/><wp:docPr id="8" name="Group"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1270000" cy="635000"/><a:chOff x="0" y="0"/><a:chExt cx="1270000" cy="635000"/></a:xfrm></wpg:grpSpPr>{}{}</wpg:wgp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#,
member(9, r#" hidden="1""#),
member(10, "")
);
let doc = format!(
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><w:body>{vml}{shown}{inline_vml}{dml}{group}</w:body></w:document>"#
);
let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
// Each drawing's flag, a group's members' after it.
let hidden = |d: &Document| {
let mut out = Vec::new();
d.objects_in_reading_order(&mut |o| {
out.extend(o.frame().map(|(_, _, f)| f.hidden));
if let InlineObject::Group { children, .. } = o {
out.extend(children.iter().filter_map(|c| c.obj.frame()).map(|(_, _, f)| f.hidden));
}
});
out
};
let want = [true, false, true, true, false, true, false];
let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
assert_eq!(hidden(&d), want);
let saved = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&saved);
let marked =
|tag: &str| xml.split(&format!("<{tag} ")).skip(1).filter(|a| a.split('>').next().is_some_and(|a| a.contains(r#"hidden="1""#))).count();
assert_eq!((marked("wp:docPr"), marked("wps:cNvPr")), (3, 1), "{xml}");
assert_eq!(hidden(&wordcraft_docx::read(&saved).unwrap()), want);
}