//! 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 { 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 { 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#""#), "{xml}"); assert!(xml.contains(r#""#) && xml.contains(r#""#)); assert!(xml.contains(r#""#) && xml.contains(r#""#)); 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#"{inner}"# ) } #[test] fn hostile_links_read_without_chains_running_wild() { let head = wsp( r#"Text"#, ); // A repeated seq, a link to a chain with no first box, a seq that isn't a number. let body = [ head, wsp(r#""#), wsp(r#""#), wsp(r#""#), wsp(r#""#), ] .concat(); let root = r#""#; let doc = format!( r#"{body}"# ); 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#"Boxed"#; let shape = |style: &str, textbox: &str| { format!( r#"{content}"# ) }; 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#"{content}"#), ] .concat(); let doc = format!( r#"{body}"# ); let rels = br#""#; let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap(); let bodies: Vec = 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 = 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#""#; let shown = r#""#; let inline_vml = r#""#; let dml = r#""#; let member = |id: u32, hidden: &str| { format!( r#""# ) }; let group = format!( r#"{}{}"#, member(9, r#" hidden="1""#), member(10, "") ); let doc = format!( r#"{vml}{shown}{inline_vml}{dml}{group}"# ); let rels = br#""#; // 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); }