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 <noreply@anthropic.com>
This commit is contained in:
pakiatandClaude Opus 5.5 committed 2026-11-14 09:33:37 +00:00
1 parent f0f4758c01
commit 0e83ef6d51
5 files changed
+222 -2

No files matched your search

+2
View File
@@ -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.
+59
View File
@@ -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)
}
}
+115
View File
@@ -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<u8> {
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
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 rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdS" 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 {ns}><w:body>{body}</w:body></w:document>"#);
let st = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles {ns}>{styles}</w: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#"<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Angsana New" w:hAnsi="Angsana New" w:cs="Tahoma"/><w:bCs/><w:sz w:val="44"/><w:szCs w:val="22"/>{extra}</w:rPr></w:rPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="CsPara"><w:name w:val="CsPara"/><w:basedOn w:val="Normal"/><w:rPr><w:cs/></w:rPr></w:style>
<w:style w:type="character" w:styleId="CsChar"><w:name w:val="CsChar"/><w:rPr><w:cs/></w:rPr></w:style>
<w:style w:type="character" w:styleId="CsOff"><w:name w:val="CsOff"/><w:rPr><w:cs w:val="0"/></w:rPr></w:style>
<w:style w:type="character" w:styleId="RtlChar"><w:name w:val="RtlChar"/><w:rPr><w:rtl/></w:rPr></w:style>
<w:style w:type="table" w:styleId="CsTable"><w:name w:val="CsTable"/><w:rPr><w:cs/></w:rPr></w:style>
<w:style w:type="table" w:styleId="SizeTable"><w:name w:val="SizeTable"/><w:rPr><w:sz w:val="30"/><w:szCs w:val="16"/></w:rPr></w:style>"#
)
};
let latin = ("Angsana New", 22.0, false);
let complex = ("Tahoma", 11.0, true);
let run = |rpr: &str| format!(r#"<w:p><w:r><w:rPr>{rpr}</w:rPr><w:t xml:space="preserve">{thai}</w:t></w:r></w:p>"#);
let styled = |p: &str, rpr: &str| {
format!(r#"<w:p><w:pPr><w:pStyle w:val="{p}"/></w:pPr><w:r><w:rPr>{rpr}</w:rPr><w:t xml:space="preserve">{thai}</w:t></w:r></w:p>"#)
};
let in_table = |style: &str| {
format!(
r#"<w:tbl><w:tblPr><w:tblStyle w:val="{style}"/></w:tblPr><w:tblGrid><w:gridCol w:w="6000"/></w:tblGrid><w:tr><w:tc>{}</w:tc></w:tr></w:tbl>"#,
run("")
)
};
let cs_off = r#"<w:rStyle w:val="CsOff"/>"#;
let cases: Vec<(&str, String, String, (&str, f32, bool))> = vec![
("plain", defaults(""), run(""), latin),
("bidi language", defaults(""), run(r#"<w:lang w:val="th-TH" w:bidi="th-TH"/>"#), latin),
("hint", defaults(""), run(r#"<w:rFonts w:hint="cs"/>"#), latin),
("character style", defaults(""), run(r#"<w:rStyle w:val="CsChar"/>"#), latin),
("character style rtl", defaults(""), run(r#"<w:rStyle w:val="RtlChar"/>"#), latin),
("run cs", defaults(""), run("<w:cs/>"), complex),
("run rtl", defaults(""), run("<w:rtl/>"), complex),
("paragraph style", defaults(""), styled("CsPara", ""), complex),
("paragraph style, run cs off", defaults(""), styled("CsPara", r#"<w:cs w:val="0"/>"#), latin),
("run defaults", defaults("<w:cs/>"), 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("<w:cs/>"), 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#"<w:p><w:r><w:t>Latin only</w:t></w:r></w:p><w:p><w:r><w:t>سلام</w:t></w:r></w:p>"#;
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#"<w:p><w:pPr><w:pStyle w:val="{p}"/></w:pPr><w:r><w:rPr>{rpr}</w:rPr><w:t>Latin</w:t></w:r></w:p>"#);
let body =
latin_in("Normal", r#"<w:rStyle w:val="CsChar"/>"#) + &latin_in("CsPara", cs_off) + &latin_in("Normal", r#"<w:rStyle w:val="RtlChar"/>"#);
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}");
}
}
+10 -2
View File
@@ -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
+36
View File
@@ -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<Vec<Vec<CharProps>>> {
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) {