diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 0550557f1..770375365 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -113,6 +113,8 @@ pub struct StoryCtx { pub story_depth: usize, /// Reading a header or footer (not a text box inside one): where Word keeps its watermark. pub in_hf: bool, + /// Reading a table cell's paragraphs (not a text box inside one). + in_cell: bool, } /// Paragraph content under construction. diff --git a/crates/docx/src/read/story/blocks.rs b/crates/docx/src/read/story/blocks.rs index e72f16bf6..8a378bce8 100644 --- a/crates/docx/src/read/story/blocks.rs +++ b/crates/docx/src/read/story/blocks.rs @@ -4,6 +4,45 @@ use super::{ placeholder_of, sectpr, tcpr, trpr, }; +/// Word draws a run's text with the complex-script font, size, bold and italic only when the run +/// is complex script or right to left (`w:cs`, `w:rtl`) by its own properties, its paragraph +/// style, its table style or the run defaults; a character style saying so, or saying not, +/// doesn't count (nor does its `w:rtl` change the order of the text). Anywhere else the text takes the run's other font and `w:sz`, Thai included, +/// whatever its language or `w:hint` (measured in Word for Windows: Thai text at `w:sz` 22 pt, +/// `w:szCs` 11 pt is drawn at 22 pt). WordCraft draws Thai, Lao and Indic characters with the +/// complex-script formatting unless the run says it is not complex script, and counts a character +/// style's `w:cs`; so a run it would draw otherwise says on itself whether it is complex script +/// (`w:cs`, which Word reads the same way). `table` is the run formatting the table style gives +/// the paragraph's cell. Runs with right-to-left text are left as they are: they are written +/// right to left. +fn mark_runs_as_word_draws_them(styles: &craft_wordmodel::StyleSheet, para: &mut Paragraph, table: Option<&CharProps>) { + use craft_wordmodel::bidi::{is_complex_script, is_rtl_script}; + let style = para.props.style.as_deref(); + let mut at = 0; + for run in &mut para.runs { + let text = para.text.get(at..at + run.len).unwrap_or(""); + at += run.len; + if text.is_empty() || text.chars().any(is_rtl_script) { + continue; + } + let word = styles.resolve_char_in(style, table, &CharProps { style: None, ..run.props.clone() }); + let mut ours = styles.resolve_char_in(style, table, &run.props); + // Right to left only by its character style: Word draws and orders the run as it does + // one that says nothing (measured, Latin and Thai text). + if ours.rtl && !word.rtl { + run.props.rtl = Some(false); + ours = styles.resolve_char_in(style, table, &run.props); + } + if word.cs || word.rtl { + if !ours.cs && !ours.rtl { + run.props.cs = Some(true); + } + } else if !ours.rtl && (ours.cs || (!ours.cs_off && text.chars().any(is_complex_script))) { + run.props.cs = Some(false); + } + } +} + impl Reader<'_> { /// Read a story's blocks from a container element (`w:body`, `w:hdr`, `w:tc`, `w:txbxContent`…). pub fn read_blocks(&mut self, sc: &mut StoryCtx, parent: &El, rels: &Rels, out: &mut Blocks, depth: usize) { @@ -276,6 +315,10 @@ impl Reader<'_> { if sc.fields.iter().any(|f| f.phase == Phase::Instr) && para.plain_text().is_empty() { para.mark.hidden = Some(true); } + // A cell's paragraphs are marked once their table is read: its style counts. + if !sc.in_cell { + mark_runs_as_word_draws_them(&self.doc.styles, &mut para, None); + } para.normalize(); para } @@ -355,8 +398,10 @@ impl Reader<'_> { continue; } let mut blocks = Blocks::new(); + let outer = std::mem::replace(&mut sc.in_cell, true); self.read_blocks(sc, tc, rels, &mut blocks, depth + 4); self.flush_pending(sc, &mut blocks); + sc.in_cell = outer; row.cells.push(Cell { props, blocks, controls: cell_controls }); } if !row.cells.is_empty() { @@ -370,6 +415,20 @@ impl Reader<'_> { let cols = table.cols().clamp(1, MAX_COLS); table.grid = vec![72.0; cols]; } + // The cells' paragraphs, with the run formatting the table style gives each cell (a + // table inside has marked its own). + let chrs = self.doc.styles.table_cells_chr(&table); + for (ri, row) in table.rows.iter_mut().enumerate() { + for (ci, cell) in row.cells.iter_mut().enumerate() { + let chr = chrs.as_ref().and_then(|c| c.get(ri)?.get(ci)); + for b in &mut cell.blocks { + if let Block::Para(p) = Arc::make_mut(b) { + mark_runs_as_word_draws_them(&self.doc.styles, p, chr); + p.normalize(); + } + } + } + } Some(table) } } diff --git a/crates/docx/tests/it/bidi.rs b/crates/docx/tests/it/bidi.rs index 3df1c04c9..6dae963aa 100644 --- a/crates/docx/tests/it/bidi.rs +++ b/crates/docx/tests/it/bidi.rs @@ -175,3 +175,118 @@ fn rtl_tables_and_sections_round_trip() { assert_eq!(again, vec![true, false]); assert!(r.last_section.rtl && r.last_section.columns.count == 2); } + +/// A package whose body is `body` and whose styles part holds `styles`. +fn docx_with_styles(body: &str, styles: &str) -> Vec { + let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + let root = r#""#; + let rels = r#""#; + let doc = format!(r#"{body}"#); + let st = format!(r#"{styles}"#); + zip(&[ + ("_rels/.rels", root.as_bytes()), + ("word/_rels/document.xml.rels", rels.as_bytes()), + ("word/document.xml", doc.as_bytes()), + ("word/styles.xml", st.as_bytes()), + ]) +} + +/// Thai text draws in the complex-script formatting only where Word draws it so (measured in Word +/// for Windows; run defaults Angsana New 22 pt, complex script Tahoma 11 pt bold). Without +/// `w:cs` or `w:rtl` on the run, its paragraph style or the run defaults, Thai text measures as +/// Angsana New 22 pt, whatever its language, `w:hint` or character style. +#[test] +fn thai_text_takes_the_complex_script_formatting_only_in_a_complex_script_run() { + let thai = "ทดสอบ ภาษาไทย"; + let defaults = |extra: &str| { + format!( + r#"{extra} + + + + + + +"# + ) + }; + let latin = ("Angsana New", 22.0, false); + let complex = ("Tahoma", 11.0, true); + let run = |rpr: &str| format!(r#"{rpr}{thai}"#); + let styled = |p: &str, rpr: &str| { + format!(r#"{rpr}{thai}"#) + }; + let in_table = |style: &str| { + format!( + r#"{}"#, + run("") + ) + }; + let cs_off = r#""#; + let cases: Vec<(&str, String, String, (&str, f32, bool))> = vec![ + ("plain", defaults(""), run(""), latin), + ("bidi language", defaults(""), run(r#""#), latin), + ("hint", defaults(""), run(r#""#), latin), + ("character style", defaults(""), run(r#""#), latin), + ("character style rtl", defaults(""), run(r#""#), latin), + ("run cs", defaults(""), run(""), complex), + ("run rtl", defaults(""), run(""), complex), + ("paragraph style", defaults(""), styled("CsPara", ""), complex), + ("paragraph style, run cs off", defaults(""), styled("CsPara", r#""#), latin), + ("run defaults", defaults(""), run(""), complex), + // A character style's `w:cs w:val="0"` doesn't turn the paragraph style's or the run + // defaults' off either. + ("paragraph style, character style cs off", defaults(""), styled("CsPara", cs_off), complex), + ("run defaults, character style cs off", defaults(""), run(cs_off), complex), + // A table style's `w:cs` counts, and its sizes are the run's. + ("table style", defaults(""), in_table("CsTable"), complex), + ("table style sizes", defaults(""), in_table("SizeTable"), ("Angsana New", 15.0, false)), + ]; + let check = |d: &Document, name: &str, want: (&str, f32, bool), when: &str| { + let (p, table) = match &*d.body[0] { + craft_wordmodel::Block::Table(t) => { + (t.rows[0].cells[0].blocks[0].as_para().unwrap(), d.styles.table_cells_chr(t).map(|c| c[0][0].clone())) + } + b => (b.as_para().unwrap(), None), + }; + let rc = d.styles.resolve_char_in(p.props.style.as_deref(), table.as_ref(), &p.runs[0].props); + for ch in p.text.chars() { + let drawn = if rc.uses_complex(ch) { rc.complex() } else { rc.clone() }; + assert_eq!((drawn.font.as_str(), drawn.size, drawn.bold), want, "{name} {when}: {ch:?}"); + } + let c = &p.runs[0].props; + assert_eq!((c.font_cs.as_deref(), c.size_cs, c.bold_cs), (None, None, None), "{name} {when}: nothing is pinned"); + }; + for (name, styles, body, want) in &cases { + let d = craft_docx::read(&docx_with_styles(body, styles)).unwrap(); + check(&d, name, *want, "read"); + let again = craft_docx::read(&craft_docx::write(&d).unwrap()).unwrap(); + check(&again, name, *want, "after a save"); + } + // Latin text and right-to-left text are left as they were. + let body = r#"Latin onlyسلام"#; + let d = craft_docx::read(&docx_with_styles(body, &defaults(""))).unwrap(); + for p in paras(&d) { + assert_eq!(p.runs[0].props.cs, None, "{}", p.text); + } + // Latin text follows the same rule (measured): a character style's `w:cs` or `w:rtl` leaves it in the + // run's own formatting, a character style's `w:cs w:val="0"` under a paragraph style's + // `w:cs` in the complex-script formatting. + let latin_in = + |p: &str, rpr: &str| format!(r#"{rpr}Latin"#); + let body = + latin_in("Normal", r#""#) + &latin_in("CsPara", cs_off) + &latin_in("Normal", r#""#); + let d = craft_docx::read(&docx_with_styles(&body, &defaults(""))).unwrap(); + let again = craft_docx::read(&craft_docx::write(&d).unwrap()).unwrap(); + for (d, when) in [(&d, "read"), (&again, "after a save")] { + let drawn: Vec<_> = paras(d) + .iter() + .map(|p| { + let rc = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props); + let c = if rc.uses_complex('L') { rc.complex() } else { rc }; + (c.font, c.size) + }) + .collect(); + assert_eq!(drawn, [("Angsana New".to_string(), 22.0), ("Tahoma".to_string(), 11.0), ("Angsana New".to_string(), 22.0)], "{when}"); + } +} diff --git a/crates/wordmodel/src/resolve.rs b/crates/wordmodel/src/resolve.rs index 40449e82c..7ef12ccdc 100644 --- a/crates/wordmodel/src/resolve.rs +++ b/crates/wordmodel/src/resolve.rs @@ -66,6 +66,11 @@ pub struct ResolvedChar { pub rtl: bool, /// The whole run uses the complex-script properties (`w:cs`). pub cs: bool, + /// The run says it is not complex script (`w:cs w:val="0"`): its Thai, Indic, Arabic or + /// Hebrew characters take its other font, size, bold and italic, as Word draws them in a run + /// that isn't complex script or right to left. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub cs_off: bool, /// Complex-script font, size, bold and italic (Arabic, Persian, Hebrew… text). pub font_cs: String, /// East Asian font (Chinese, Japanese and Korean text), when one is set. @@ -115,6 +120,7 @@ impl ResolvedChar { lang: c.lang.clone(), rtl: c.rtl.unwrap_or(false), cs: c.cs.unwrap_or(false), + cs_off: c.cs == Some(false), font_cs: c.font_cs.clone().or_else(|| c.font.clone()).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()), font_ea: c.font_ea.clone(), size_cs: c.size_cs.or(c.size).unwrap_or(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0), @@ -133,9 +139,11 @@ impl ResolvedChar { pub fn kerned(&self) -> bool { self.kern > 0.0 && self.size >= self.kern } - /// Does a character of this run use the complex-script properties? + /// Does a character of this run use the complex-script properties? Every character of a + /// right-to-left or complex-script run does; otherwise a complex-script character does, + /// unless the run says it is not complex script. pub fn uses_complex(&self, c: char) -> bool { - self.rtl || self.cs || crate::bidi::is_complex_script(c) + self.rtl || self.cs || (!self.cs_off && crate::bidi::is_complex_script(c)) } /// The size glyphs are drawn and measured at. Word sets superscript and subscript text at /// 0.65 of the run's size rounded to the half point, a font size the format can store diff --git a/crates/wordmodel/src/styles.rs b/crates/wordmodel/src/styles.rs index 5383c1510..eea1659c2 100644 --- a/crates/wordmodel/src/styles.rs +++ b/crates/wordmodel/src/styles.rs @@ -828,6 +828,42 @@ impl StyleSheet { out.chr.style = None; Some(out) } + /// The run formatting `t`'s table style gives the text of each of its cells, by row and then + /// cell: each of a cell's parts' over the last, lowest precedence first (ECMA-376 §17.7.6), as + /// layout applies it between document defaults and the paragraph's style. `None` when the + /// table has no style. The style and the table's size are worked out once for the whole table, + /// and each set of parts is put together once. + pub fn table_cells_chr(&self, t: &crate::table::Table) -> Option>> { + let st = self.table_style(t.props.style.as_deref()?)?; + let sizes = (st.parts.band_size.unwrap_or(0), st.parts.col_band_size.unwrap_or(0)); + let ncols = t.cols().clamp(1, crate::table::MAX_COLS); + let nrows = t.rows.len(); + let mut made: Vec<(PartSet, CharProps)> = Vec::new(); + let mut out = Vec::with_capacity(nrows); + for (r, row) in t.rows.iter().enumerate() { + let mut g = row.first_grid(); + let mut cells = Vec::with_capacity(row.cells.len()); + for cell in &row.cells { + let span = cell.span(); + let parts = PartSet::of_cell(&t.props.look, sizes, (r, nrows), (g, span, ncols)); + g = g.saturating_add(span); + let chr = match made.iter().find(|(p, _)| *p == parts) { + Some((_, chr)) => chr.clone(), + None => { + let mut chr = CharProps::default(); + for part in parts.iter() { + chr.overlay(&st.part(part).chr); + } + made.push((parts, chr.clone())); + chr + } + }; + cells.push(chr); + } + out.push(cells); + } + Some(out) + } /// Add or replace a style. pub fn upsert(&mut self, st: Style) { match self.styles.iter_mut().find(|s| s.id == st.id) {