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 <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 04:55:44 -04:00
1 parent 326d695335
commit 1c188ba140
19 files changed
+228 -74

No files matched your search

+3 -1
View File
@@ -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) |
+4 -16
View File
@@ -303,10 +303,10 @@ pub struct CharProps {
/// inherit, then `font`.
#[serde(skip_serializing_if = "Option::is_none")]
pub font_ea: Option<String>,
/// 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<f32>,
/// 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<bool>,
#[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);
+3 -5
View File
@@ -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" => {
-11
View File
@@ -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
}
+3 -3
View File
@@ -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();
+4 -6
View File
@@ -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 {
+2 -2
View File
@@ -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;
@@ -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<u8> {
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="s1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let document = format!(
r#"<w:document {ns}><w:body><w:p><w:pPr>{paragraph_style}<w:bidi/></w:pPr><w:r><w:rPr>{run}</w:rPr><w:t>שָׁלוֹם ABC 123</w:t></w:r></w:p></w:body></w:document>"#
);
let styles = format!("<w:styles {ns}><w:docDefaults><w:rPrDefault><w:rPr>{defaults}</w:rPr></w:rPrDefault></w:docDefaults>{styles}</w: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#"<w:rFonts w:ascii="Source Serif 4" w:hAnsi="Source Serif 4" w:cs="Arial Hebrew"/><w:b w:val="0"/><w:bCs/><w:i w:val="0"/><w:iCs/><w:sz w:val="24"/><w:szCs w:val="48"/><w:rtl/><w:cs/><w:lang w:val="en-US" w:bidi="he-IL"/>"#;
check_generations(&package(run, "", "", ""), |d| {
let c = &paragraph(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#"<w:szCs w:val="36"/><w:bCs/><w:iCs/>"#;
let run = r#"<w:sz w:val="20"/><w:b w:val="0"/><w:i w:val="0"/>"#;
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#"
<w:style w:type="paragraph" w:styleId="HebrewBase"><w:name w:val="HebrewBase"/><w:rPr><w:rFonts w:cs="Arial Hebrew"/><w:szCs w:val="36"/><w:bCs/><w:iCs/><w:lang w:bidi="he-IL"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="MixedChild"><w:name w:val="MixedChild"/><w:basedOn w:val="HebrewBase"/><w:rPr><w:sz w:val="24"/><w:b w:val="0"/><w:i w:val="0"/></w:rPr></w:style>
<w:style w:type="character" w:styleId="LatinOnly"><w:name w:val="LatinOnly"/><w:rPr><w:sz w:val="20"/><w:b w:val="0"/><w:i w:val="0"/></w:rPr></w:style>"#;
let run = r#"<w:rStyle w:val="LatinOnly"/><w:rFonts w:ascii="Source Serif 4" w:hAnsi="Source Serif 4"/>"#;
check_generations(&package(run, "", styles, r#"<w:pStyle w:val="MixedChild"/>"#), |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#"<w:sz w:val="20"/><w:szCs w:val="20"/><w:b w:val="0"/><w:bCs w:val="0"/><w:i w:val="0"/><w:iCs w:val="0"/>"#;
let defaults = r#"<w:szCs w:val="36"/><w:bCs/><w:iCs/>"#;
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#"<w:szCs w:val="20"/><w:bCs w:val="0"/><w:iCs w:val="0"/><w:rtl w:val="0"/><w:cs w:val="0"/>"#;
let defaults = r#"<w:sz w:val="36"/><w:b/><w:i/>"#;
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#"<w:szCs w:val="36"/><w:bCs/><w:iCs/>"#;
let run = r#"<w:sz w:val="24"/><w:b w:val="0"/><w:i w:val="0"/><w:rPrChange w:id="7" w:author="Synthetic Author" w:date="2026-10-11T00:00:00Z"><w:rPr><w:sz w:val="20"/><w:szCs w:val="20"/><w:b w:val="0"/><w:bCs w:val="0"/><w:i w:val="0"/><w:iCs w:val="0"/></w:rPr></w:rPrChange>"#;
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);
});
}
+2 -1
View File
@@ -1146,7 +1146,8 @@ fn formatting_revisions_round_trip() {
assert!(xml.contains(parent), "{tag} last: {xml}");
}
assert!(xml.contains(r#"<w:numberingChange w:id="#) && xml.contains(r#"w:original="1."/></w:numPr>"#), "{xml}");
assert!(xml.contains(r#"<w:b/><w:bCs/><w:rPrChange w:id="#), "{xml}");
assert!(xml.contains(r#"<w:b/><w:rPrChange w:id="#), "{xml}");
assert!(!xml.contains("<w:bCs"), "Saving must not add an absent complex-script bold: {xml}");
let again = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(again.body, d.body);
assert_eq!(again.last_section, d.last_section);
+12 -8
View File
@@ -15,15 +15,14 @@ pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0
pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
|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))
}
+1 -1
View File
@@ -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();
+3 -2
View File
@@ -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"}));
+42
View File
@@ -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 = "سلام دنیا";
+2 -4
View File
@@ -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();
+2 -4
View File
@@ -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() {
+1 -1
View File
@@ -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();
+3 -2
View File
@@ -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) |
+2 -1
View File
@@ -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 |
+4 -6
View File
@@ -169,13 +169,11 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
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)) {