From 0e83ef6d5173be33fd78376922e49b699db501b2 Mon Sep 17 00:00:00 2001 From: pakiat <49967840+pakiat@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:33:37 +0000 Subject: [PATCH 1/3] craft-docx: Thai text outside a complex-script run takes the run's own font and size, as in Word Moved from the DOCX part of storytold/wordcraft#6244. Runs are marked with what Word counts as complex script (the run, its paragraph style chain, its table style and the run defaults, not a character style). StyleSheet::table_cells_chr works out a table's cell run formatting once; ResolvedChar gains cs_off. Co-Authored-By: Claude Opus 5.5 --- crates/docx/src/read/story.rs | 2 + crates/docx/src/read/story/blocks.rs | 59 ++++++++++++++ crates/docx/tests/it/bidi.rs | 115 +++++++++++++++++++++++++++ crates/wordmodel/src/resolve.rs | 12 ++- crates/wordmodel/src/styles.rs | 36 +++++++++ 5 files changed, 222 insertions(+), 2 deletions(-) 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) { From 24907cbd7a0d96fe90be6921286036a140c4655c Mon Sep 17 00:00:00 2001 From: pakiat <49967840+pakiat@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:41:38 +0000 Subject: [PATCH 2/3] craft-wordmodel: mark runs as Word draws them in the model, for every reader StyleSheet::mark_runs_as_word_draws_them moves from the DOCX reader into the model, unchanged, so the .doc reader can apply the same rule. Co-Authored-By: Claude Opus 5.5 --- crates/docx/src/read/story/blocks.rs | 43 ++-------------------------- crates/wordmodel/src/styles.rs | 38 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 41 deletions(-) diff --git a/crates/docx/src/read/story/blocks.rs b/crates/docx/src/read/story/blocks.rs index 8a378bce8..5d7d3711e 100644 --- a/crates/docx/src/read/story/blocks.rs +++ b/crates/docx/src/read/story/blocks.rs @@ -4,45 +4,6 @@ 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) { @@ -317,7 +278,7 @@ impl Reader<'_> { } // 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); + self.doc.styles.mark_runs_as_word_draws_them(&mut para, None); } para.normalize(); para @@ -423,7 +384,7 @@ impl Reader<'_> { 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); + self.doc.styles.mark_runs_as_word_draws_them(p, chr); p.normalize(); } } diff --git a/crates/wordmodel/src/styles.rs b/crates/wordmodel/src/styles.rs index eea1659c2..81d8cd272 100644 --- a/crates/wordmodel/src/styles.rs +++ b/crates/wordmodel/src/styles.rs @@ -828,6 +828,44 @@ impl StyleSheet { out.chr.style = None; Some(out) } + /// 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), and the same in a `.doc`. 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). A reader calls this on each paragraph it has read. `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. + pub fn mark_runs_as_word_draws_them(&self, para: &mut crate::para::Paragraph, table: Option<&CharProps>) { + use crate::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 = self.resolve_char_in(style, table, &CharProps { style: None, ..run.props.clone() }); + let mut ours = self.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 = self.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); + } + } + } /// 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 From 9a5a35334a05ce86a28b22479eddbfc4742d238e Mon Sep 17 00:00:00 2001 From: pakiat <49967840+pakiat@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:52:27 +0000 Subject: [PATCH 3/3] craft-docx: a field's Thai result takes the complex-script formatting only where Word does Measured in Word for Windows: Thai text as a field's result follows the rule ordinary text does. mark_runs_as_word_draws_them counts the result of a field kept as one object as the text of its run. Co-Authored-By: Claude Opus 5.5 --- crates/docx/tests/it/bidi.rs | 37 ++++++++++++++++++++++++++++++++++ crates/wordmodel/src/styles.rs | 22 ++++++++++++++++---- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/crates/docx/tests/it/bidi.rs b/crates/docx/tests/it/bidi.rs index 6dae963aa..bea7e2131 100644 --- a/crates/docx/tests/it/bidi.rs +++ b/crates/docx/tests/it/bidi.rs @@ -290,3 +290,40 @@ fn thai_text_takes_the_complex_script_formatting_only_in_a_complex_script_run() assert_eq!(drawn, [("Angsana New".to_string(), 22.0), ("Tahoma".to_string(), 11.0), ("Angsana New".to_string(), 22.0)], "{when}"); } } + +/// Measured in Word for Windows: Thai text as a field's result follows the same rule as ordinary +/// text, also when the reader keeps the field as one object with its result. +#[test] +fn a_fields_thai_result_takes_the_complex_script_formatting_only_in_a_complex_script_run() { + let styles = r#" +"#; + let field = |rpr: &str| { + let r = |inner: &str| format!("{rpr}{inner}"); + format!( + "{}{}{}{}{}", + r(r#""#), + r(r#" QUOTE "x" "#), + r(r#""#), + r("ทดสอบ"), + r(r#""#) + ) + }; + let drawn = |d: &Document| { + let p = paras(d)[0]; + let at = p.text.find(craft_wordmodel::para::OBJ).expect("the field is one object"); + let run = p.runs.iter().scan(0, |end, r| { + let start = *end; + *end += r.len; + Some((start, r)) + }); + let (_, run) = run.into_iter().find(|(start, r)| *start <= at && at < start + r.len).unwrap(); + let rc = d.styles.resolve_char(p.props.style.as_deref(), &run.props); + if rc.uses_complex('ท') { (rc.font_cs.clone(), rc.size_cs) } else { (rc.font.clone(), rc.size) } + }; + for (what, rpr, want) in [("nothing", "", ("Angsana New", 10.0)), ("w:cs", "", ("Tahoma", 20.0))] { + let d = craft_docx::read(&docx_with_styles(&field(rpr), styles)).unwrap(); + assert_eq!(drawn(&d), (want.0.to_string(), want.1), "a run with {what}: read"); + let again = craft_docx::read(&craft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(drawn(&again), (want.0.to_string(), want.1), "a run with {what}: after a save"); + } +} diff --git a/crates/wordmodel/src/styles.rs b/crates/wordmodel/src/styles.rs index 81d8cd272..1d725efa2 100644 --- a/crates/wordmodel/src/styles.rs +++ b/crates/wordmodel/src/styles.rs @@ -841,11 +841,25 @@ impl StyleSheet { /// right to left. pub fn mark_runs_as_word_draws_them(&self, para: &mut crate::para::Paragraph, table: Option<&CharProps>) { use crate::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(""); + use crate::para::{InlineObject, OBJ}; + // What each run draws: its text, a field kept as one object standing for its result + // (drawn in the run's formatting, and measured in Word to follow the same rule). + let mut drawn = Vec::with_capacity(para.runs.len()); + let (mut at, mut objects) = (0, para.objects.iter()); + for run in ¶.runs { + let mut text = String::new(); + for c in para.text.get(at..at + run.len).unwrap_or("").chars() { + if c != OBJ { + text.push(c); + } else if let Some(InlineObject::Field { result, code: false, .. }) = objects.next() { + text.push_str(result); + } + } at += run.len; + drawn.push(text); + } + let style = para.props.style.as_deref(); + for (run, text) in para.runs.iter_mut().zip(&drawn) { if text.is_empty() || text.chars().any(is_rtl_script) { continue; }