//! Saving as Word 2003 XML (#1081): documents built in code, written as WordprocessingML 2003 //! and read back with the 2003 reader. use std::sync::Arc; use craft_docx::{XmlKind, read_xml, sniff_xml, write_wordml2003}; use craft_wordmodel::numbering::ListKind; use craft_wordmodel::para::NoteKind; use craft_wordmodel::props::{Align, CharProps, NumRef, Rgb, TextColor}; use craft_wordmodel::table::{Cell, Table}; use craft_wordmodel::{Block, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block}; fn tiny_png() -> Vec { let img = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 80, y as u8 * 120, 200, 255])); let mut out = std::io::Cursor::new(Vec::new()); img.write_to(&mut out, image::ImageFormat::Png).unwrap(); out.into_inner() } 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 sample() -> Document { let mut d = Document::new(); d.core.title = "Garden & more".into(); d.core.creator = "Ada".into(); let key = d.add_media(tiny_png(), "png"); let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered); let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]); let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Sown in May.", CharProps::default()))]); let com = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Too short?", CharProps::default()))]); d.comments.insert(3, Comment { author: "Bo".into(), part: com, ..Default::default() }); d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new(), move_name: None }); d.last_section.headers.default = Some(head); let mut p = Paragraph::new(); for (t, c) in [ ("Plain ", CharProps::default()), ( "loud", CharProps { bold: Some(true), italic: Some(true), size: Some(14.0), color: Some(TextColor::Rgb(Rgb(255, 0, 0))), ..Default::default() }, ), (" link", CharProps { link: Some("https://example.org/beans?a=1&b=2".into()), ..Default::default() }), ] { let off = p.len(); p.insert_text(off, t, &c).unwrap(); } p.props.align = Some(Align::Center); p.props.indent_left = Some(36.0); p.props.space_after = Some(12.0); p.insert_object(0, InlineObject::CommentStart { id: 3 }, &CharProps::default()).unwrap(); let end = p.len(); p.insert_object(end, InlineObject::CommentEnd { id: 3 }, &CharProps::default()).unwrap(); let at = p.text.find("loud").unwrap(); p.insert_object(at, InlineObject::NoteRef { kind: NoteKind::Footnote, id: foot, custom: String::new() }, &CharProps::default()).unwrap(); let mut blocks = vec![para_block(p)]; let mut li = Paragraph::with_text("listed", CharProps { ins: Some(0), ..Default::default() }); li.props.numbering = Some(NumRef { num, level: 0 }); blocks.push(para_block(li)); let mut img = Paragraph::new(); img.insert_object( 0, InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], fx: Default::default(), ole: None, }, &CharProps::default(), ) .unwrap(); blocks.push(para_block(img)); let mut t = Table::new(2, 2, 300.0); t.rows[0].cells[0] = Cell::with_text("A1"); t.rows[0].cells[1] = Cell::with_text("B1"); t.rows[1].cells[0].props.shading = Some(Rgb(0, 128, 0)); blocks.push(Arc::new(Block::Table(t))); blocks.push(para_block(Paragraph::new())); d.body = blocks; d } #[test] fn round_trips_through_the_2003_reader() { let d = sample(); let xml = write_wordml2003(&d).expect("write"); let text = std::str::from_utf8(&xml).expect("utf-8"); assert!(text.contains("") && text.contains("") && text.contains(" & more"); assert_eq!(back.core.creator, "Ada"); let ps = paras(&back); assert_eq!(ps[0].plain_text(), "Plain loud link"); assert_eq!(ps[0].props.align, Some(Align::Center)); assert_eq!(ps[0].props.indent_left, Some(36.0)); assert_eq!(ps[0].props.space_after, Some(12.0)); let c = ps[0].props_of_char(ps[0].text.find("loud").unwrap()); assert_eq!((c.bold, c.italic, c.size), (Some(true), Some(true), Some(14.0))); assert!(c.color.is_some()); assert_eq!(ps[0].props_at(ps[0].text.find("link").unwrap()).link.as_deref(), Some("https://example.org/beans?a=1&b=2")); let note = ps[0].objects.iter().find_map(|o| match o { InlineObject::NoteRef { id, .. } => Some(*id), _ => None, }); assert_eq!(part_text(&back, note), "Sown in May."); let comment = back.comments.values().next().expect("comment"); assert_eq!((comment.author.as_str(), part_text(&back, Some(comment.part)).as_str()), ("Bo", "Too short?")); let listed = ps[1]; assert!(listed.props.numbering.is_some(), "list paragraph keeps its list"); let ins = listed.props_of_char(0).ins.and_then(|i| back.revisions.get(i as usize)); assert_eq!(ins.map(|r| r.author.as_str()), Some("Ada")); let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. })); assert!(matches!(pic, Some(InlineObject::Image { w, h, alt, .. }) if *w == 30.0 && *h == 20.0 && alt == "dot")); assert_eq!(back.media.len(), 1); let table = back.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")); assert_eq!(table.rows[1].cells[0].props.shading, Some(Rgb(0, 128, 0))); assert_eq!(part_text(&back, back.last_section.headers.default), "Head"); } /// Features 2003 XML can't carry drop out without an error, and the text stays. #[test] fn control_characters_and_unsupported_content_do_not_break_the_file() { let mut d = Document::new(); let mut p = Paragraph::with_text("a\u{1}b\u{FFFE}c <&> \"q\"", CharProps { size: Some(f32::NAN), ..Default::default() }); p.insert_object( 0, InlineObject::Opaque { format: "docx".into(), xml: "raw".into(), text: "raw".into() }, &CharProps::default(), ) .unwrap(); d.body = vec![para_block(p)]; let xml = write_wordml2003(&d).expect("write"); assert!(read_xml(&xml).is_ok()); let text = String::from_utf8(xml).expect("utf-8"); assert!(!text.contains('\u{1}') && !text.contains('\u{FFFE}')); assert!(!text.contains(" = paras(&back) .iter() .flat_map(|p| p.objects.iter()) .filter_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None }) .collect(); assert_eq!(got.len(), cases.len()); for (g, want) in got.iter().zip(cases) { let near = |a: i32, b: i32| (a - b).abs() <= 2; assert!(near(g.bright, want.bright) && near(g.contrast, want.contrast) && g.transparency == 0, "{g:?} vs {want:?}"); } } #[test] fn shared_header_and_repeated_note_are_not_inlined_per_reference() { // #3652: one ~10 KB header shared by 1000 sections and one ~10 KB footnote referenced 500 // times must not be copied into the output once per section or reference. let mut d = Document::new(); let big = format!("HDRMARK {}", "h".repeat(10_000)); let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text(&big, CharProps::default()))]); let note = format!("NOTEMARK {}", "n".repeat(10_000)); let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text(¬e, CharProps::default()))]); d.last_section.headers.default = Some(head); let mut blocks = Vec::new(); for i in 0..1000 { let mut p = Paragraph::with_text("x", CharProps::default()); if i < 500 { p.insert_object(1, InlineObject::NoteRef { kind: NoteKind::Footnote, id: foot, custom: String::new() }, &CharProps::default()).unwrap(); } p.section = Some(Box::new(d.last_section.clone())); blocks.push(para_block(p)); } d.body = blocks; let xml = write_wordml2003(&d).expect("write"); assert!(read_xml(&xml).is_ok()); let text = String::from_utf8(xml).expect("utf-8"); let hdr = text.matches("HDRMARK").count(); let notes = text.matches("NOTEMARK").count(); assert!(text.len() < 2_000_000 && hdr <= 3 && notes <= 3, "output {} bytes, header x{hdr}, note x{notes}", text.len()); } #[test] fn headers_past_the_inlined_byte_budget_fail_the_export() { // #3652: two ~1 MB headers alternating over 80 sections must each be written where they // change; past the byte budget the export fails rather than growing or dropping a header. let mut d = Document::new(); let a = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text(&"a".repeat(1_000_000), CharProps::default()))]); let b = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text(&"b".repeat(1_000_000), CharProps::default()))]); let mut blocks = Vec::new(); for i in 0..80 { let mut p = Paragraph::with_text("x", CharProps::default()); let mut s = d.last_section.clone(); s.headers.default = Some(if i % 2 == 0 { a } else { b }); p.section = Some(Box::new(s)); blocks.push(para_block(p)); } d.body = blocks; let out = write_wordml2003(&d); assert!(matches!(out, Err(craft_docx::DocxError::Limit(_))), "expected a limit error, got {:?}", out.map(|x| x.len())); }