From 1c188ba140073fae5a119dd9df755b07adc5fcd3 Mon Sep 17 00:00:00 2001 From: shirizhaki Date: Mon, 12 Oct 2026 11:55:44 +0300 Subject: [PATCH] M1: preserve independent complex-script DOCX formatting (#366) Keep inherited complex-script size, bold and italic independent from Latin properties and retain explicit values during DOCX round trips. Preserve ordinary formatting-command behavior and add regression coverage. Resolve eight existing Clippy warnings without changing behavior so all six required CI gates pass. Co-authored-by: ChatGPT Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/doc/src/props.rs | 20 +-- crates/docx/src/read/math.rs | 8 +- crates/docx/src/read/props.rs | 11 -- crates/docx/src/write/embed.rs | 6 +- crates/docx/src/write/props.rs | 10 +- crates/docx/tests/bidi.rs | 4 +- .../docx/tests/complex_script_inheritance.rs | 135 ++++++++++++++++++ crates/docx/tests/fixtures.rs | 3 +- crates/engine/src/cmd/format.rs | 20 +-- crates/engine/src/cmd/review.rs | 2 +- crates/engine/src/tests.rs | 5 +- crates/engine/src/tests_bidi.rs | 42 ++++++ crates/formats/src/odt.rs | 6 +- crates/formats/src/rtf.rs | 6 +- crates/ui-egui/src/dialogs_insert.rs | 2 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 3 +- xtask/src/layers.rs | 10 +- 19 files changed, 228 insertions(+), 74 deletions(-) create mode 100644 crates/docx/tests/complex_script_inheritance.rs diff --git a/ROADMAP.md b/ROADMAP.md index cba0440..3c88b3f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (independent complex-script formatting preserved across DOCX open/save; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and agent control. This page is the summary; the assessment is @@ -134,6 +134,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged | | 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) | |---|---| +| 2026-10-11 | DOCX open/save keeps inherited complex-script size, bold and italic independent of Latin formatting; explicit values are retained, and font commands still format both scripts. Covered by synthetic round trips and a local GUI save/reopen check. | | 2026-10-11 | Czech (`cs`) interface catalog: 2,021 entries (the full zh-hans key set), Czech locale detection and persisted language choice | | 2026-10-11 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish | | 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources | @@ -212,6 +213,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | DOCX complex-script formatting inheritance fix and round-trip coverage | | 2026-10-11 | minor | Czech (`cs`) interface catalog added | | 2026-10-11 | trivial | Progress log: German interface (#574) | | 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) | diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 4405d4d..6487e72 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -303,10 +303,10 @@ pub struct CharProps { /// inherit, then `font`. #[serde(skip_serializing_if = "Option::is_none")] pub font_ea: Option, - /// Complex-script size, points (`w:szCs`). `None` = the same as `size` (see [`CharProps::overlay`]). + /// Complex-script size, points (`w:szCs`). `None` leaves the inherited value unchanged. #[serde(skip_serializing_if = "Option::is_none")] pub size_cs: Option, - /// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` = the same as `bold` / `italic`. + /// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` leaves the inherited value unchanged. #[serde(skip_serializing_if = "Option::is_none")] pub bold_cs: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -390,11 +390,8 @@ impl CharProps { /// Apply every `Some` field of `patch` on top of `self`. /// - /// Size, bold and italic come in pairs with their complex-script values (Word's own buttons - /// set both): a patch that sets the plain value and leaves the complex-script one `None` sets - /// both, so a run's size or bold isn't overridden for its Persian/Arabic characters by an - /// inherited `w:szCs` / `w:bCs`. The complex-script font inherits on its own (Word often - /// sets only the Latin font and lets complex-script text keep the style's). + /// Plain and complex-script properties inherit independently. Commands that intend to + /// format both scripts must set both values explicitly. pub fn overlay(&mut self, patch: &CharProps) { overlay_fields!(self, patch; style, 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, @@ -402,15 +399,6 @@ impl CharProps { if let Some(p) = &patch.text_effects { self.text_effects.get_or_insert_with(Default::default).overlay(p); } - if patch.size.is_some() && patch.size_cs.is_none() { - self.size_cs = None; - } - if patch.bold.is_some() && patch.bold_cs.is_none() { - self.bold_cs = None; - } - if patch.italic.is_some() && patch.italic_cs.is_none() { - self.italic_cs = None; - } } pub fn overlaid(mut self, patch: &CharProps) -> CharProps { self.overlay(patch); diff --git a/crates/docx/src/read/math.rs b/crates/docx/src/read/math.rs index d5d12c7..c6a8adc 100644 --- a/crates/docx/src/read/math.rs +++ b/crates/docx/src/read/math.rs @@ -219,11 +219,9 @@ fn node(k: &El, depth: usize, out: &mut Arg) { }); } // Tracked insertions show; deletions don't. - "w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" => { - if depth <= MAX_DEPTH { - for c in k.els() { - node(c, d, out); - } + "w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" if depth <= MAX_DEPTH => { + for c in k.els() { + node(c, d, out); } } "w:sdt" => { diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 7a307db..3cbab1b 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -189,17 +189,6 @@ impl PropCtx { _ => {} } } - // A complex-script value equal to its plain one is the same as none (see - // `CharProps::overlay`), which keeps documents round-tripping exactly. - if c.size_cs.is_some() && c.size_cs == c.size { - c.size_cs = None; - } - if c.bold_cs.is_some() && c.bold_cs == c.bold { - c.bold_cs = None; - } - if c.italic_cs.is_some() && c.italic_cs == c.italic { - c.italic_cs = None; - } c } diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 5f66f0b..8d43ec6 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -527,9 +527,9 @@ fn vml_style_with(style: &str, w: f32, h: f32, float: &Float) -> String { .split(';') .filter(|d| { let key = d.split_once(':').map_or("", |(k, _)| k).trim().to_ascii_lowercase(); - !d.trim().is_empty() - && !matches!(key.as_str(), "width" | "height" | "rotation" | "flip") - && !(absolute && matches!(key.as_str(), "left" | "top" | "margin-left" | "margin-top")) + !(d.trim().is_empty() + || matches!(key.as_str(), "width" | "height" | "rotation" | "flip") + || (absolute && matches!(key.as_str(), "left" | "top" | "margin-left" | "margin-top"))) }) .map(|d| d.trim().to_string()) .collect(); diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index b9e0837..2cfe983 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -26,9 +26,7 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { if let Some(s) = &c.style { w.val("w:rStyle", s); } - // The complex-script font is written only when set: without it, Persian/Arabic text keeps - // the style's. Complex-script bold, italic and size default to the plain ones (they come in - // pairs, see `CharProps::overlay`), so text formatted here looks the same in Word. + // Emit only explicit properties so absent values keep inheriting from the style chain. let mut fonts: Vec<(&str, &str)> = Vec::new(); if let Some(f) = &c.font { fonts.extend([("w:ascii", f.as_str()), ("w:hAnsi", f.as_str())]); @@ -43,9 +41,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { w.empty("w:rFonts", &fonts); } toggle(w, "w:b", c.bold); - toggle(w, "w:bCs", c.bold_cs.or(c.bold)); + toggle(w, "w:bCs", c.bold_cs); toggle(w, "w:i", c.italic); - toggle(w, "w:iCs", c.italic_cs.or(c.italic)); + toggle(w, "w:iCs", c.italic_cs); toggle(w, "w:caps", c.caps); toggle(w, "w:smallCaps", c.small_caps); toggle(w, "w:strike", c.strike); @@ -77,7 +75,7 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { if let Some(s) = c.size { w.val("w:sz", &half(s)); } - if let Some(s) = c.size_cs.or(c.size) { + if let Some(s) = c.size_cs { w.val("w:szCs", &half(s)); } if let Some(h) = c.highlight { diff --git a/crates/docx/tests/bidi.rs b/crates/docx/tests/bidi.rs index ede391b..68b008b 100644 --- a/crates/docx/tests/bidi.rs +++ b/crates/docx/tests/bidi.rs @@ -114,8 +114,8 @@ fn reads_word_style_bidi_markup() { assert_eq!(fa.font.as_deref(), Some("Times New Roman")); assert_eq!(fa.font_cs.as_deref(), Some("B Lotus")); assert_eq!((fa.size, fa.size_cs), (Some(12.0), Some(16.0))); - // b + bCs alike is stored once: an unset complex-script bold is the same as `bold`. - assert_eq!((fa.bold, fa.bold_cs, fa.rtl), (Some(true), None, Some(true))); + // Both explicit values survive, even when they happen to be equal. + assert_eq!((fa.bold, fa.bold_cs, fa.rtl), (Some(true), Some(true), Some(true))); assert_eq!((fa.lang.as_deref(), fa.lang_bidi.as_deref()), (Some("en-US"), Some("fa-IR"))); assert_eq!(ps[1].props.bidi, Some(false)); let x = &ps[1].runs[0].props; diff --git a/crates/docx/tests/complex_script_inheritance.rs b/crates/docx/tests/complex_script_inheritance.rs new file mode 100644 index 0000000..92e11ea --- /dev/null +++ b/crates/docx/tests/complex_script_inheritance.rs @@ -0,0 +1,135 @@ +//! Independent complex-script formatting through style inheritance and DOCX round trips. +//! Synthetic packages, written here; no external documents or installed fonts are needed. + +use std::io::Write; +use wordcraft_doc::{Document, Paragraph}; + +fn package(run: &str, defaults: &str, styles: &str, paragraph_style: &str) -> Vec { + let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + let root = r#""#; + let rels = r#""#; + let document = format!( + r#"{paragraph_style}{run}שָׁלוֹם ABC 123"# + ); + let styles = format!("{defaults}{styles}"); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, data) in [ + ("_rels/.rels", root), + ("word/_rels/document.xml.rels", rels), + ("word/document.xml", document.as_str()), + ("word/styles.xml", styles.as_str()), + ] { + z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(data.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() +} + +fn paragraph(d: &Document) -> &Paragraph { + d.body.iter().find_map(|b| b.as_para()).unwrap() +} + +fn check_generations(bytes: &[u8], check: impl Fn(&Document)) { + let mut d = wordcraft_docx::read(bytes).unwrap(); + for _ in 0..3 { + check(&d); + let next = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(paragraph(&next).text, paragraph(&d).text); + assert_eq!(paragraph(&next).runs, paragraph(&d).runs); + assert_eq!(paragraph(&next).props.bidi, Some(true)); + d = next; + } +} + +#[test] +fn separate_hebrew_font_size_language_and_flags_survive() { + let run = r#""#; + check_generations(&package(run, "", "", ""), |d| { + let c = ¶graph(d).runs[0].props; + assert_eq!((c.font.as_deref(), c.font_cs.as_deref()), (Some("Source Serif 4"), Some("Arial Hebrew"))); + assert_eq!((c.size, c.size_cs), (Some(12.0), Some(24.0))); + assert_eq!((c.bold, c.bold_cs, c.italic, c.italic_cs), (Some(false), Some(true), Some(false), Some(true))); + assert_eq!((c.rtl, c.cs), (Some(true), Some(true))); + assert_eq!((c.lang.as_deref(), c.lang_bidi.as_deref()), (Some("en-US"), Some("he-IL"))); + }); +} + +#[test] +fn latin_direct_properties_leave_complex_script_defaults_inherited() { + let defaults = r#""#; + let run = r#""#; + check_generations(&package(run, defaults, "", ""), |d| { + let p = paragraph(d); + let c = &p.runs[0].props; + assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (None, None, None), "Absent direct values must remain absent when saved"); + let resolved = d.styles.resolve_char(p.props.style.as_deref(), c); + assert_eq!((resolved.size, resolved.bold, resolved.italic), (10.0, false, false)); + let cs = resolved.complex(); + assert_eq!((cs.size, cs.bold, cs.italic), (18.0, true, true)); + }); +} + +#[test] +fn latin_style_properties_leave_complex_script_style_chain_inherited() { + let styles = r#" + + +"#; + let run = r#""#; + check_generations(&package(run, "", styles, r#""#), |d| { + let p = paragraph(d); + let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props); + assert_eq!((c.font.as_str(), c.size, c.bold, c.italic), ("Source Serif 4", 10.0, false, false)); + let cs = c.complex(); + assert_eq!((cs.font.as_str(), cs.size, cs.bold, cs.italic), ("Arial Hebrew", 18.0, true, true)); + assert_eq!(d.styles.get("HebrewBase").unwrap().chr.lang_bidi.as_deref(), Some("he-IL")); + }); +} + +#[test] +fn equal_explicit_complex_script_values_are_not_discarded() { + let run = r#""#; + let defaults = r#""#; + check_generations(&package(run, defaults, "", ""), |d| { + let p = paragraph(d); + let c = &p.runs[0].props; + assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(false), Some(false))); + let cs = d.styles.resolve_char(p.props.style.as_deref(), c).complex(); + assert_eq!((cs.size, cs.bold, cs.italic), (10.0, false, false)); + }); +} + +#[test] +fn complex_script_direct_properties_leave_latin_defaults_inherited() { + let run = r#""#; + let defaults = r#""#; + check_generations(&package(run, defaults, "", ""), |d| { + let p = paragraph(d); + let c = &p.runs[0].props; + assert_eq!((c.size, c.bold, c.italic), (None, None, None)); + assert_eq!((c.rtl, c.cs), (Some(false), Some(false))); + let resolved = d.styles.resolve_char(p.props.style.as_deref(), c); + assert_eq!((resolved.size, resolved.bold, resolved.italic), (18.0, true, true)); + let cs = resolved.complex(); + assert_eq!((cs.size, cs.bold, cs.italic), (10.0, false, false)); + }); +} + +#[test] +fn tracked_formatting_keeps_independent_complex_script_values() { + let defaults = r#""#; + let run = r#""#; + check_generations(&package(run, defaults, "", ""), |d| { + let p = paragraph(d); + let c = &p.runs[0].props; + let current = d.styles.resolve_char(None, c).complex(); + assert_eq!((current.size, current.bold, current.italic), (18.0, true, true)); + let change = c.fmt_change.as_ref().unwrap(); + assert_eq!((change.old.size_cs, change.old.bold_cs, change.old.italic_cs), (Some(10.0), Some(false), Some(false))); + let old = d.styles.resolve_char(None, &change.old).complex(); + assert_eq!((old.size, old.bold, old.italic), (10.0, false, false)); + let mut overlaid = wordcraft_doc::props::CharProps::default(); + overlaid.overlay(c); + assert_eq!(overlaid.fmt_change, c.fmt_change); + }); +} diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 6d0bc5c..9742806 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1146,7 +1146,8 @@ fn formatting_revisions_round_trip() { assert!(xml.contains(parent), "{tag} last: {xml}"); } assert!(xml.contains(r#""#), "{xml}"); - assert!(xml.contains(r#" Vec { vec![ CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| { - // Like Word's button, for complex-script (Persian, Arabic) text too: an unset `bold_cs` - // follows `bold`. + // The button formats both plain and complex-script text explicitly. toggle( s, v, |r| r.bold, |c, on| { c.bold = Some(on); - c.bold_cs = None; + c.bold_cs = Some(on); }, ) }) @@ -36,7 +35,7 @@ pub fn specs() -> Vec { |r| r.italic, |c, on| { c.italic = Some(on); - c.italic_cs = None; + c.italic_cs = Some(on); }, ) }) @@ -319,7 +318,7 @@ fn size(s: &mut Session, v: &Value) -> CmdResult { let sz = (sz * 2.0).round() / 2.0; apply(s, &|c| { c.size = Some(sz); - c.size_cs = None; + c.size_cs = Some(sz); }) } @@ -338,7 +337,7 @@ fn step_size(s: &mut Session, dir: i32) -> CmdResult { // Each run steps from its own size when the selection mixes sizes. apply(s, &|c| { c.size = Some(next); - c.size_cs = None; + c.size_cs = Some(next); }) } @@ -346,7 +345,7 @@ fn nudge_size(s: &mut Session, d: f32) -> CmdResult { let next = (current_size(s) + d).clamp(1.0, 1638.0); apply(s, &|c| { c.size = Some(next); - c.size_cs = None; + c.size_cs = Some(next); }) } @@ -568,7 +567,12 @@ fn char_style(s: &mut Session, v: &Value) -> CmdResult { } fn set(s: &mut Session, v: &Value) -> CmdResult { - let props: CharProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?; + let mut props: CharProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?; + // The font dialog and callers supplying one value format both scripts. Explicit + // complex-script values still take precedence; importing a DOCX never uses this command. + props.size_cs = props.size_cs.or(props.size); + props.bold_cs = props.bold_cs.or(props.bold); + props.italic_cs = props.italic_cs.or(props.italic); apply(s, &|c| c.overlay(&props)) } diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 228f45f..2ce9d4a 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -1200,7 +1200,7 @@ mod tests { let list = s.run("review.changes", &json!({})).unwrap(); let desc: Vec<&str> = list.as_array().unwrap().iter().filter(|c| c["kind"] == "format").filter_map(|c| c["description"].as_str()).collect(); - assert_eq!(desc, ["Formatted: Align: center", "Formatted: Bold, Size: 14"], "{list:#}"); + assert_eq!(desc, ["Formatted: Align: center", "Formatted: Bold, Bold cs, Size: 14, Size cs: 14"], "{list:#}"); let (d, body_x) = draws(&mut s); let bars = lines(&d).into_iter().filter(|(x0, _, x1, _)| x0 == x1 && *x0 < body_x).count(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b21b8ab..5961310 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2428,12 +2428,13 @@ fn style_inspector_reports_and_clears_levels() { assert!(pdirect.iter().any(|d| d["prop"] == "align" && d["value"] == "center"), "{pdirect:?}"); assert!(r["character"]["style"].is_null()); let cdirect = r["character"]["direct"].as_array().unwrap(); - assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true})]); + assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true}), json!({"prop": "boldCs", "value": true})]); // Formatting that matches the style isn't a difference (Heading 1 is 20 pt). run(&mut s, "format.size", json!({"size": 20})); let r = run(&mut s, "styles.inspect", json!({})); - assert_eq!(r["character"]["direct"].as_array().unwrap().len(), 1, "{r}"); + let cdirect = r["character"]["direct"].as_array().unwrap(); + assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true}), json!({"prop": "boldCs", "value": true})]); // Character style level. run(&mut s, "format.charStyle", json!({"style": "Emphasis"})); diff --git a/crates/engine/src/tests_bidi.rs b/crates/engine/src/tests_bidi.rs index e7151a5..8f6224f 100644 --- a/crates/engine/src/tests_bidi.rs +++ b/crates/engine/src/tests_bidi.rs @@ -89,6 +89,48 @@ fn font_commands_reach_persian_text() { assert_eq!(d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi, Some(true)); } +#[test] +fn font_commands_override_inherited_complex_script_properties_explicitly() { + use wordcraft_doc::props::CharProps; + + let mut s = session("שלום ABC", true); + s.doc.styles.default_chr.size_cs = Some(30.0); + s.doc.styles.default_chr.bold_cs = Some(true); + s.doc.styles.default_chr.italic_cs = Some(true); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({"value": false})); + run(&mut s, "format.italic", json!({"value": false})); + run(&mut s, "format.size", json!({"size": 18})); + for (command, expected_size) in [ + (None, 18.0), + (Some("format.growFont"), 20.0), + (Some("format.shrinkFont"), 18.0), + (Some("format.growFont1"), 19.0), + (Some("format.shrinkFont1"), 18.0), + ] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c = p.props_of_char(0); + assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(expected_size), Some(false), Some(false))); + let d = crate::io::open_bytes("x.docx", &crate::io::save_bytes("x.docx", &s.doc).unwrap()).unwrap(); + let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0); + let cs = d.styles.resolve_char(None, c).complex(); + assert_eq!((cs.size, cs.bold, cs.italic), (expected_size, false, false)); + } + // The font dialog's command keeps formatting both scripts when given one value. + run(&mut s, "format.set", json!({"props": {"size": 10, "bold": true, "italic": true}})); + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c: &CharProps = p.props_of_char(0); + assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(true), Some(true))); + // Callers can still supply distinct values explicitly, including false. + run(&mut s, "format.set", json!({"props": {"size": 12, "sizeCs": 18, "bold": true, "boldCs": false, "italic": true, "italicCs": false}})); + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c = p.props_of_char(0); + assert_eq!((c.size, c.size_cs, c.bold_cs, c.italic_cs), (Some(12.0), Some(18.0), Some(false), Some(false))); +} + #[test] fn arrow_keys_move_visually_in_rtl_text() { let fa = "سلام دنیا"; diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 4f98fda..e2266f7 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -1222,10 +1222,8 @@ impl Body<'_> { } match local(q) { "p" | "h" if !self.in_desc => self.end_para(), - "span" | "a" => { - if self.fmt.len() > usize::from(self.para.is_some()) { - self.fmt.pop(); - } + "span" | "a" if self.fmt.len() > usize::from(self.para.is_some()) => { + self.fmt.pop(); } "list" => { self.end_para(); diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 24ac30f..0e57fa5 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -1003,10 +1003,8 @@ impl Reader { self.list_cur.clear(); self.list_cur_id = 0; } - "listlevel" => { - if self.list_cur.len() < 9 { - self.list_cur.push(true); - } + "listlevel" if self.list_cur.len() < 9 => { + self.list_cur.push(true); } "levelnfc" | "levelnfcn" => { if let Some(l) = self.list_cur.last_mut() { diff --git a/crates/ui-egui/src/dialogs_insert.rs b/crates/ui-egui/src/dialogs_insert.rs index 2454aca..5fe7563 100644 --- a/crates/ui-egui/src/dialogs_insert.rs +++ b/crates/ui-egui/src/dialogs_insert.rs @@ -623,7 +623,7 @@ impl SymbolForm { self.chars = face.chars().into_iter().map(|(c, _)| c).filter(|c| !c.is_control() && !(0xD800..=0xDFFF).contains(&(*c as u32))).collect(); self.chars_of = Some(family); // Keep the selection when the new font has it; otherwise its first character. - if !self.selected.is_some_and(|c| self.chars.binary_search(&c).is_ok()) { + if self.selected.is_none_or(|c| self.chars.binary_search(&c).is_err()) { self.selected = self.chars.iter().copied().find(|c| *c > ' ').or(self.chars.first().copied()); } self.sync_code(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a0ff0fc..c5da6ca 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (file associations and the default-app button, #295; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -46,7 +46,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Feature | Read | Write | Notes | |---|---|---|---| -| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | +| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | | Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | | | Hyphenation settings (`w:autoHyphenation`, `w:hyphenationZone`, `w:doNotHyphenateCaps`, `w:consecutiveHyphenLimit`) | ✅ | ✅ | Zone, capitals and limit kept and honoured by layout (#407) | @@ -161,6 +161,7 @@ raise readiness estimates. No Word-derived files are fixtures. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Independent complex-script size, bold and italic inherit correctly through defaults and styles; explicit equal values survive; six synthetic multi-generation round-trip tests, including tracked formatting, added | | 2026-10-11 | minor | Opening files from the system: file associations and the default-app button (#295) | | 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% | | 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) | diff --git a/docs/gaps.md b/docs/gaps.md index e7acc23..578532f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: .doc pictures and drawings read, #551; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (DOCX complex-script formatting inheritance fixed; broader RTL work remains; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent @@ -69,6 +69,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | #16: DOCX open/save preserves independent complex-script formatting inheritance; RTL sections, tables and typography remain open | | 2026-10-11 | trivial | `.doc` pictures, shapes and text boxes read (#551); gap #14 narrowed to the writer and Word 6/95 files | | 2026-10-11 | trivial | Table column and row borders and the resize handle drag on the page (#217); UI/UX gap narrowed to the ruler's column markers | | 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains | diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 9341e2c..a1f43d8 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -169,13 +169,11 @@ pub fn check(crates: &[Crate]) -> Vec { continue; } match classify(&d.name) { - // Unregistered deps are reported on their own entry. - None => {} - Some(Class::Testkit) => { - if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); - } + Some(Class::Testkit) if d.kind != DepKind::Dev => { + out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); } + // Unregistered deps are reported on their own entry; testkit dev deps are allowed. + None | Some(Class::Testkit) => {} Some(dc) => { let to = dc.layer().unwrap_or(u8::MAX); if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {