//! Word XML files (#548): Flat OPC (Word XML Document) and Word 2003 XML, from hand-written //! samples (never files produced by Word). use base64::Engine as _; use wordcraft_doc::props::Align; use wordcraft_doc::section::NumFormat; use wordcraft_doc::{Block, Document, InlineObject, Paragraph}; use wordcraft_docx::{DocxError, XmlKind, read_xml, sniff_xml}; fn paras(doc: &Document) -> Vec<&Paragraph> { doc.body.iter().filter_map(|b| b.as_para()).collect() } fn part_text(doc: &Document, id: Option) -> String { id.and_then(|i| doc.parts.get(&i)) .map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::>().join("\n")) .unwrap_or_default() } fn tiny_png_b64() -> String { let img = image::RgbaImage::from_fn(2, 2, |x, y| image::Rgba([x as u8 * 100, y as u8 * 100, 50, 255])); let mut out = std::io::Cursor::new(Vec::new()); img.write_to(&mut out, image::ImageFormat::Png).unwrap(); let b64 = base64::engine::general_purpose::STANDARD.encode(out.into_inner()); // Line breaks inside base64, as the formats write it. b64.as_bytes().chunks(20).map(|c| std::str::from_utf8(c).unwrap()).collect::>().join("\n") } /// A minimal Flat OPC package written by hand: namespaces declared on the package element only, /// a hyperlink relationship, a picture in base64 and an empty part. #[test] fn reads_a_hand_written_flat_opc_package() { let png = tiny_png_b64(); let xml = format!( r#" Seed & soil link {png} "# ); assert_eq!(sniff_xml(xml.as_bytes()), XmlKind::FlatOpc); let doc = read_xml(xml.as_bytes()).unwrap(); let ps = paras(&doc); assert_eq!(ps[0].plain_text(), "Seed & soil"); assert_eq!(ps[0].props_at(0).bold, Some(true)); assert_eq!(ps[1].props_at(0).link.as_deref(), Some("https://example.org/flat")); assert!(matches!(ps[2].objects.first(), Some(InlineObject::Image { w, h, .. }) if *w == 24.0 && *h == 12.0)); assert_eq!(doc.media.len(), 1); } /// A Word 2003 XML Document, hand-written from the Office 2003 XML Reference Schemas. fn wordml_2003_sample() -> String { let png = tiny_png_b64(); format!( r##" Garden notesAda Gardenerbeans PlanToo short? Plain loud thenmore Sown in May. bean guide Water the beans daily A1B1 B2 {png} Garden header "## ) } #[test] fn imports_word_2003_xml() { let xml = wordml_2003_sample(); assert_eq!(sniff_xml(xml.as_bytes()), XmlKind::WordMl2003); let doc = read_xml(xml.as_bytes()).unwrap(); assert_eq!(doc.core.title, "Garden notes"); assert_eq!(doc.core.creator, "Ada Gardener"); let h1 = doc.styles.get("Heading1").expect("Heading1 style"); assert_eq!(h1.chr.bold, Some(true)); assert_eq!(h1.chr.size, Some(16.0)); let ps = paras(&doc); assert_eq!(ps[0].plain_text(), "Plan"); assert_eq!(ps[0].props.style.as_deref(), Some("Heading1")); assert!(ps[0].objects.iter().any(|o| matches!(o, InlineObject::BookmarkStart { name, .. } if name == "plan"))); let p = ps[1]; assert_eq!(p.props.align, Some(Align::Center)); assert_eq!(p.props.indent_first, Some(18.0)); let loud = p.text.find("loud").unwrap(); let c = p.props_of_char(loud); assert_eq!((c.bold, c.italic, c.size, c.font.as_deref()), (Some(true), Some(true), Some(14.0), Some("Courier New"))); assert!(c.underline.is_some() && c.color.is_some()); assert!(p.plain_text().contains("Plain loud\tthen")); let note = p.objects.iter().find_map(|o| match o { InlineObject::NoteRef { id, .. } => Some(*id), _ => None, }); assert_eq!(part_text(&doc, note), "Sown in May."); assert_eq!(ps[2].props_at(0).link.as_deref(), Some("https://example.org/beans")); let listed = ps[3]; let num = listed.props.numbering.expect("list paragraph"); let lvl = doc.numbering.level(num.num, num.level).expect("list level"); assert_eq!((lvl.format, lvl.text.as_str(), lvl.indent), (NumFormat::LowerLetter, "%1)", 36.0)); let ins = listed.props_of_char(listed.text.find("daily").unwrap()).ins.and_then(|i| doc.revisions.get(i as usize)); assert_eq!(ins.map(|r| r.author.as_str()), Some("Ada")); let comment = doc.comments.values().next().expect("comment"); assert_eq!((comment.author.as_str(), part_text(&doc, Some(comment.part)).as_str()), ("Bo", "Too short?")); let table = doc.body.iter().find_map(|b| match b.as_ref() { Block::Table(t) => Some(t), _ => None, }); let table = table.expect("table"); assert_eq!(table.rows.len(), 2); assert_eq!(table.rows[0].cells[1].blocks.first().and_then(|b| b.as_para()).map(|p| p.plain_text()).as_deref(), Some("B1")); let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. })); assert!(matches!(pic, Some(InlineObject::Image { w, h, .. }) if *w == 36.0 && *h == 18.0)); assert_eq!(doc.media.len(), 1); assert_eq!(doc.last_section.page_w, 612.0); assert_eq!(doc.last_section.margin_left, 90.0); assert_eq!(part_text(&doc, doc.last_section.headers.default), "Garden header"); } #[test] fn hostile_word_xml_is_an_error_not_a_panic() { let flat = r#""#; let w2003 = r#""#; // Other XML (and non-XML) is refused with the Word XML error. for other in ["hi", "", "not xml at all", ""] { assert_eq!(read_xml(other.as_bytes()), Err(DocxError::NotWordXml), "{other:?}"); } // Broken packages: errors. for bad in [ flat.to_string(), format!("{flat}"), format!("{flat}!!!not base64"), format!("{flat}"), ] { assert!(read_xml(bad.as_bytes()).is_err(), "{bad}"); } // Lenient leftovers never panic. for odd in [ format!("{flat}"), format!( "{w2003}%%%" ), format!( "{w2003}" ), format!("{w2003}{}", "".repeat(5000)), ] { let _ = read_xml(odd.as_bytes()); } // Too many parts. let mut many = String::from(flat); for i in 0..20_001 { many.push_str(&format!("AA==")); } many.push_str(""); assert!(matches!(read_xml(many.as_bytes()), Err(DocxError::Limit(_)))); } /// A Flat OPC package of one paragraph with a picture, whose `pkg:binaryData` holds `data` as /// written, and `extra` after the parts. fn picture_package(data: &str, extra: &str) -> String { format!( r#" Seed {data}{extra} "# ) } /// A binary part's base64 reads the same as plain text, as CDATA or with character references; /// an entity in it is an error rather than bytes gone missing (#1353). #[test] fn flat_opc_binary_data_reads_the_same_in_cdata_and_character_references() { let png = tiny_png_b64(); let media = |data: &str| read_xml(picture_package(data, "").as_bytes()).map(|d| d.media.values().cloned().collect::>()); let plain = media(&png).unwrap(); assert_eq!(plain.len(), 1); let (head, tail) = png.split_at(png.len() / 2); let first = png.chars().next().unwrap(); for other in [format!(""), format!("{head}"), format!("&#{};{}", u32::from(first), &png[1..]).replace('\n', " ")] { assert_eq!(media(&other).unwrap(), plain, "{other}"); } assert!(media(&format!("&{png}")).is_err()); } /// Work anywhere in a Flat OPC file is bounded, not only in the parts that are kept: nesting /// and elements no part reader looks at, and repeated part names, count too (#1353). #[test] fn flat_opc_limits_count_what_is_not_kept() { let png = tiny_png_b64(); let limited = |extra: &str| matches!(read_xml(picture_package(&png, extra).as_bytes()), Err(DocxError::Limit(_))); assert!(read_xml(picture_package(&png, "").as_bytes()).is_ok()); // Deep nesting outside the parts. assert!(limited(&format!("{}{}", "".repeat(1000), "".repeat(1000)))); // One part name over and over, or other elements, past the part count. assert!(limited(&r#"AA=="#.repeat(20_000))); assert!(limited(&"".repeat(20_000))); // A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000). assert!(limited(&format!(r#"{}"#, "".repeat(8_000_000)))); }