mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-25 19:07:17 -06:00
* 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.
199 lines
12 KiB
Rust
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);
|
|
}
|