//! Right-to-left and complex-script OOXML: `w:bidi` paragraphs, `w:rtl` / `w:cs` runs, the //! complex-script font, size, bold and italic, and the bidi language. Fixtures are hand-written //! from ECMA-376 Part 1 §17.3.1.6 (bidi), §17.3.2 (run properties). use std::io::{Read, Write}; use wordcraft_doc::props::{Align, CharProps, ParaProps}; use wordcraft_doc::{Document, Paragraph, 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() } /// A package whose body is `body`. fn docx(body: &str) -> Vec { let root = r#""#; let doc = format!( r#"{body}"# ); zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())]) } fn paras(d: &Document) -> Vec<&Paragraph> { d.body.iter().filter_map(|b| b.as_para()).collect() } /// `word/document.xml` of a written package. 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 } /// Persian text with a ZWNJ (نیم‌فاصله), Persian digits, Latin and punctuation. const FA: &str = "می‌خواهم نسخهٔ ۲٫۵ از WordCraft را (امروز) نصب کنم."; fn persian_run() -> CharProps { CharProps { rtl: Some(true), font_cs: Some("B Nazanin".into()), size_cs: Some(14.0), bold_cs: Some(true), italic_cs: Some(false), lang_bidi: Some("fa-IR".into()), ..Default::default() } } #[test] fn persian_paragraph_round_trips() { let mut rtl = Paragraph::with_text(FA, persian_run()); rtl.props = ParaProps { bidi: Some(true), align: Some(Align::Justify), ..Default::default() }; let ltr = Paragraph::with_text("English with فارسی inside", CharProps { cs: Some(true), ..Default::default() }); let mut d = Document::new(); d.body = vec![para_block(rtl.clone()), para_block(ltr.clone())]; let bytes = wordcraft_docx::write(&d).unwrap(); let r = wordcraft_docx::read(&bytes).unwrap(); let ps = paras(&r); assert_eq!(ps[0].text, FA, "Persian text, ZWNJ and digits survive byte for byte"); assert_eq!(ps[0].props.bidi, Some(true)); assert_eq!(ps[0].props.align, Some(Align::Justify)); assert_eq!(ps[0].runs[0].props, persian_run()); assert_eq!(ps[1].text, ltr.text); assert_eq!(ps[1].props.bidi, None); assert_eq!(ps[1].runs[0].props.cs, Some(true)); } #[test] fn bidi_markup_is_written_in_schema_order() { let mut p = Paragraph::with_text(FA, CharProps { bold: Some(true), italic: Some(true), size: Some(12.0), font: Some("Arial".into()), ..persian_run() }); p.props.bidi = Some(true); let mut d = Document::new(); d.body = vec![para_block(p)]; let xml = document_xml(&wordcraft_docx::write(&d).unwrap()); assert!(xml.contains(""), "paragraph direction: {xml}"); assert!(xml.contains(r#"w:cs="B Nazanin""#), "complex-script font"); assert!(xml.contains(r#""#), "complex-script size in half points"); assert!(xml.contains(r#"w:bidi="fa-IR""#), "bidi language"); // CT_RPr is a sequence: b, bCs, i, iCs … sz, szCs … rtl, cs, lang. let pos = |t: &str| xml.find(t).unwrap_or_else(|| panic!("{t} missing: {xml}")); assert!(pos("")); assert!(pos("") < pos("") && pos("") < pos("")); assert!(pos("") < pos(r#""#)); assert!(pos(r#""#) < pos("") < pos(" سلام دنیا Word 2025 x"#; let d = wordcraft_docx::read(&docx(body)).unwrap(); let ps = paras(&d); assert_eq!(ps[0].text, "سلام دنیا Word 2025"); assert_eq!(ps[0].props.bidi, Some(true)); assert_eq!(ps[0].props.indent_left, Some(36.0), "w:left is the start indent"); assert_eq!(ps[0].props.align, Some(Align::Left), "jc=left is the start edge"); let fa = &ps[0].runs[0].props; assert_eq!(fa.font.as_deref(), Some("Times New Roman")); assert_eq!(fa.font_cs.as_deref(), Some("B Lotus")); assert_eq!((fa.size, fa.size_cs), (Some(12.0), Some(16.0))); // Both explicit values survive, even when they happen to be equal. assert_eq!((fa.bold, fa.bold_cs, fa.rtl), (Some(true), Some(true), Some(true))); assert_eq!((fa.lang.as_deref(), fa.lang_bidi.as_deref()), (Some("en-US"), Some("fa-IR"))); assert_eq!(ps[1].props.bidi, Some(false)); let x = &ps[1].runs[0].props; assert_eq!((x.cs, x.italic_cs, x.italic), (Some(true), Some(true), None)); // The resolved complex-script formatting is what Persian characters are drawn with. let rc = d.styles.resolve_char(None, fa); let cs = rc.complex(); assert_eq!((cs.font.as_str(), cs.size, cs.bold), ("B Lotus", 16.0, true)); assert!(rc.uses_complex('س') && rc.uses_complex('W'), "every character of an rtl run is complex script"); } #[test] fn hostile_bidi_markup_is_bounded() { let long = "x".repeat(10_000); let body = format!( r#"ok"# ); let d = wordcraft_docx::read(&docx(&body)).unwrap(); let c = ¶s(&d)[0].runs[0].props; assert_eq!(c.font_cs, None, "absurd font names are dropped"); assert_eq!(c.lang_bidi, None); assert!(c.size_cs.is_none_or(|s| (1.0..=1638.0).contains(&s))); } /// Right-to-left tables (`w:tblPr/w:bidiVisual`, ECMA-376 Part 1 §17.4.1) and sections /// (`w:sectPr/w:bidi`, §17.6.1) read, write in schema order and round-trip (#362). #[test] fn rtl_tables_and_sections_round_trip() { let body = r#" اولدوم "#; let d = wordcraft_docx::read(&docx(body)).unwrap(); let tables: Vec<&wordcraft_doc::Table> = d.body.iter().filter_map(|b| b.as_table()).collect(); assert_eq!(tables.len(), 2); assert!(tables[0].props.bidi_visual, "w:bidiVisual reads as a right-to-left table"); assert_eq!(tables[0].props.indent, Some(18.0), "the indent is kept (measured from the right)"); assert!(!tables[1].props.bidi_visual, "w:val=0 turns it off"); assert!(d.last_section.rtl, "w:bidi in sectPr is a right-to-left section"); let bytes = wordcraft_docx::write(&d).unwrap(); let xml = document_xml(&bytes); assert_eq!(xml.matches("").count(), 1, "{xml}"); // CT_TblPr is a sequence: tblStyle, tblpPr, tblOverlap, bidiVisual, … tblW, jc, … tblInd. let pos = |t: &str| xml.find(t).unwrap_or_else(|| panic!("{t} missing: {xml}")); assert!(pos("") < pos(" = r.body.iter().filter_map(|b| b.as_table()).map(|t| t.props.bidi_visual).collect(); assert_eq!(again, vec![true, false]); assert!(r.last_section.rtl && r.last_section.columns.count == 2); }