diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index d9bef64d1..89179a07e 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -421,6 +421,9 @@ pub struct CharProps { /// BCP 47 language of complex-script text (`w:lang/@w:bidi`, e.g. `fa-IR`). #[serde(skip_serializing_if = "Option::is_none")] pub lang_bidi: Option, + /// BCP 47 language of East Asian text (`w:lang/@w:eastAsia`, e.g. `zh-CN`, `ja-JP`, `ko-KR`). + #[serde(skip_serializing_if = "Option::is_none")] + pub lang_ea: Option, /// East Asian typography: two lines in one and horizontal in vertical (`w:eastAsianLayout`). /// Kept for round trip; layout doesn't apply it yet. #[serde(skip_serializing_if = "Option::is_none")] @@ -549,7 +552,7 @@ impl CharProps { pub fn minus(mut self, base: &CharProps) -> CharProps { drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, - cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, ligatures); + cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, ligatures); self } @@ -560,7 +563,7 @@ impl CharProps { pub fn overlay(&mut self, patch: &CharProps) { overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, - cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, fmt_change, ligatures); + cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, fmt_change, ligatures); if let Some(p) = &patch.text_effects { self.text_effects.get_or_insert_with(Default::default).overlay(p); } diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 4d924c06d..3bc08d64d 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -227,8 +227,9 @@ impl PropCtx { c.east_asian_layout = (l != EastAsianLayout::default()).then_some(l); } "w:lang" => { - c.lang = k.attr("w:val").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string); - c.lang_bidi = k.attr("w:bidi").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string); + c.lang = k.attr("w:val").map(str::trim).filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string); + c.lang_bidi = k.attr("w:bidi").map(str::trim).filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string); + c.lang_ea = k.attr("w:eastAsia").map(str::trim).filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string); } "w14:ligatures" => c.ligatures = k.attr("w14:val").and_then(wordcraft_doc::props::Ligatures::from_ooxml), n if n.starts_with("w14:") => { diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 14937ee2e..aba9fdf46 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -106,11 +106,18 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { } toggle(w, "w:rtl", c.rtl); toggle(w, "w:cs", c.cs); - match (&c.lang, &c.lang_bidi) { - (Some(l), Some(b)) => w.empty("w:lang", &[("w:val", l), ("w:bidi", b)]), - (Some(l), None) => w.val("w:lang", l), - (None, Some(b)) => w.empty("w:lang", &[("w:bidi", b)]), - (None, None) => {} + let mut lang_attrs: Vec<(&str, &str)> = Vec::with_capacity(3); + if let Some(l) = &c.lang { + lang_attrs.push(("w:val", l)); + } + if let Some(ea) = &c.lang_ea { + lang_attrs.push(("w:eastAsia", ea)); + } + if let Some(b) = &c.lang_bidi { + lang_attrs.push(("w:bidi", b)); + } + if !lang_attrs.is_empty() { + w.empty("w:lang", &lang_attrs); } // After w:lang (CT_RPr's sequence). if let Some(l) = &c.east_asian_layout { diff --git a/crates/docx/tests/east_asian_lang.rs b/crates/docx/tests/east_asian_lang.rs new file mode 100644 index 000000000..7188bb56d --- /dev/null +++ b/crates/docx/tests/east_asian_lang.rs @@ -0,0 +1,85 @@ +//! East Asian language attribute of a run (`w:lang/@w:eastAsia`, ISO/IEC 29500-1 §17.3.2.20). +//! Verifies that saving DOCX documents preserves the East Asian language of `w:lang`. + +use std::io::{Read, Write}; + +fn docx(body: &str, styles: Option<&str>) -> Vec { + let root = r#""#; + let doc_rels = r#""#; + let doc = format!( + r#"{body}"# + ); + let mut parts = vec![("_rels/.rels", root.to_string()), ("word/document.xml", doc)]; + if let Some(s) = styles { + parts.push(("word/_rels/document.xml.rels", doc_rels.to_string())); + parts.push(( + "word/styles.xml", + format!(r#"{s}"#), + )); + } + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, xml) in &parts { + zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(xml.as_bytes()).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +fn part(bytes: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut s = String::new(); + z.by_name(name).unwrap().read_to_string(&mut s).unwrap(); + s +} + +fn run(text: &str, rpr: &str) -> String { + format!(r#"{rpr}{text}"#) +} + +#[test] +fn east_asian_lang_survives_a_save() { + let body = format!( + r#"{}{}"#, + run("Hello", r#""#), + run("中文", r#""#) + ); + let d = wordcraft_docx::read(&docx(&body, None)).unwrap(); + let p = d.body[0].as_para().unwrap(); + + let r0 = &p.runs[0]; + assert_eq!(r0.props.lang.as_deref(), Some("en-US")); + assert_eq!(r0.props.lang_ea.as_deref(), Some("ja-JP")); + assert_eq!(r0.props.lang_bidi.as_deref(), Some("ar-SA")); + + let r1 = &p.runs[1]; + assert_eq!(r1.props.lang, None); + assert_eq!(r1.props.lang_ea.as_deref(), Some("zh-CN")); + assert_eq!(r1.props.lang_bidi, None); + + let out = wordcraft_docx::write(&d).unwrap(); + let xml = part(&out, "word/document.xml"); + assert!(xml.contains(r#"w:eastAsia="ja-JP""#), "{xml}"); + assert!(xml.contains(r#"w:eastAsia="zh-CN""#), "{xml}"); + + let back = wordcraft_docx::read(&out).unwrap(); + let back_p = back.body[0].as_para().unwrap(); + assert_eq!(back_p.runs[0].props.lang.as_deref(), Some("en-US")); + assert_eq!(back_p.runs[0].props.lang_ea.as_deref(), Some("ja-JP")); + assert_eq!(back_p.runs[0].props.lang_bidi.as_deref(), Some("ar-SA")); + assert_eq!(back_p.runs[1].props.lang_ea.as_deref(), Some("zh-CN")); +} + +#[test] +fn east_asian_lang_style_and_whitespace_trim() { + let styles = + r#""#; + let body = format!(r#"{}"#, run("한국어", r#""#)); + let d = wordcraft_docx::read(&docx(&body, Some(styles))).unwrap(); + + let style = d.styles.get("CJKLang").unwrap(); + assert_eq!(style.chr.lang_ea.as_deref(), Some("ko-KR")); + + let out = wordcraft_docx::write(&d).unwrap(); + let styles_xml = part(&out, "word/styles.xml"); + assert!(styles_xml.contains(r#"w:eastAsia="ko-KR""#), "{styles_xml}"); +} diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 430713bb3..a101a081c 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -113,6 +113,7 @@ fn every_char_prop_round_trips() { bold_cs: Some(false), italic_cs: Some(true), lang_bidi: Some("fa-IR".into()), + lang_ea: Some("ja-JP".into()), east_asian_layout: Some(wordcraft_doc::props::EastAsianLayout { id: Some(3), combine: Some(true), diff --git a/crates/layout/src/outline.rs b/crates/layout/src/outline.rs index bbbd463cc..0716809bb 100644 --- a/crates/layout/src/outline.rs +++ b/crates/layout/src/outline.rs @@ -124,6 +124,7 @@ fn plain_chars(c: &CharProps) -> CharProps { rtl: c.rtl, cs: c.cs, lang_bidi: c.lang_bidi.clone(), + lang_ea: c.lang_ea.clone(), link: c.link.clone(), ins: c.ins, del: c.del,