mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 20:07:19 -06:00
fix(docx): preserve East Asian language w:eastAsia on w:lang
Preserve w:lang/@w:eastAsia when reading and writing DOCX documents. Previously, only w:val and w:bidi were handled, causing w:eastAsia to be dropped on save. Fixes #3457
This commit is contained in:
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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);
|
||||
}
|
||||
|
||||
@@ -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:") => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{s}</w:styles>"#),
|
||||
));
|
||||
}
|
||||
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#"<w:r><w:rPr>{rpr}</w:rPr><w:t>{text}</w:t></w:r>"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_lang_survives_a_save() {
|
||||
let body = format!(
|
||||
r#"<w:p>{}{}</w:p>"#,
|
||||
run("Hello", r#"<w:lang w:val="en-US" w:eastAsia="ja-JP" w:bidi="ar-SA"/>"#),
|
||||
run("中文", r#"<w:lang w:eastAsia="zh-CN"/>"#)
|
||||
);
|
||||
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#"<w:style w:type="character" w:styleId="CJKLang"><w:name w:val="CJK Language"/><w:rPr><w:lang w:eastAsia=" ko-KR "/></w:rPr></w:style>"#;
|
||||
let body = format!(r#"<w:p>{}</w:p>"#, run("한국어", r#"<w:rStyle w:val="CJKLang"/>"#));
|
||||
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}");
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user