diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 49ba6a545..95625360e 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -1114,8 +1114,11 @@ struct Reader { colors: Vec>, color_cur: (u8, u8, u8, bool), styles: HashMap, + /// Each style's own character formatting, as its `\stylesheet` entry writes it. + style_defs: HashMap, style_cur: i32, style_name: String, + style_def_cur: StyleDef, meta: Meta, info_text: String, // Lists: list id → ordered per level; override ls → list id. @@ -1384,9 +1387,55 @@ impl Reader { fn commit_style(&mut self) { let name = std::mem::take(&mut self.style_name).trim().trim_end_matches(';').trim().to_string(); + let def = std::mem::take(&mut self.style_def_cur); if self.styles.len() < 4096 { self.styles.insert(self.style_cur, name); + self.style_defs.insert(self.style_cur, def); } + // The next entry is style 0 (Normal) unless it names a number. + self.style_cur = 0; + } + + /// The character formatting style `id` gives its runs in this file: its stylesheet entry's, + /// then its `\sbasedon` ancestors' (a bounded walk: loops and missing ids end it). + fn file_style(&self, id: i32) -> StyleDef { + let mut out = StyleDef::default(); + let mut seen: Vec = Vec::new(); + let mut cur = Some(id); + while let Some(i) = cur { + if seen.len() >= 16 || seen.contains(&i) { + break; + } + seen.push(i); + let Some(d) = self.style_defs.get(&i) else { break }; + out.bold = out.bold.or(d.bold); + out.italic = out.italic.or(d.italic); + out.size = out.size.or(d.size); + out.font = out.font.or(d.font); + cur = d.based_on; + } + out + } + + /// The formatting a paragraph of kind `kind` in style `id` gives its runs. A style whose + /// entry and `\sbasedon` chain set no character formatting (a bare `{\s2 heading 2;}`) has + /// WordCraft's own (`style_fmt`); otherwise what the chain doesn't set is RTF's default (not + /// bold, not italic, 12 pt). The font alone falls back per property: WordCraft writes the code + /// font on each code paragraph, not in its Code entry. + fn style_base(&self, kind: Kind, id: i32) -> Fmt { + let mut f = style_fmt(kind); + let d = self.file_style(id); + if d.bold.is_some() || d.italic.is_some() || d.size.is_some() || d.font.is_some() { + f.bold = d.bold.unwrap_or(false); + f.italic = d.italic.unwrap_or(false); + f.size = Some(d.size.unwrap_or(12.0)); + } + // A font is matched the way `\f` sets a run's: a monospaced one is the code font. + if let Some(name) = d.font.and_then(|i| self.fonts.get(&i)) { + f.code = model::is_mono(name); + f.font = (!f.code).then(|| name.clone()); + } + f } fn finish_pict(&mut self) { @@ -1526,19 +1575,27 @@ impl Reader { self.text(c.encode_utf8(&mut buf)); } - fn para_kind(&self, st: &St) -> Kind { + /// The paragraph kind `st`'s style names, if it names one. + fn style_kind(&self, st: &St) -> Option { let name = self.styles.get(&st.style).map(|s| s.to_ascii_lowercase()).unwrap_or_default(); if let Some(n) = name.strip_prefix("heading").and_then(|r| r.trim().parse::().ok()) { - return Kind::Heading(n.clamp(1, 6)); + return Some(Kind::Heading(n.clamp(1, 6))); } if st.style != 0 { match name.as_str() { - "title" => return Kind::Title, - "quote" | "intense quote" => return Kind::Quote, - "code" | "html preformatted" | "plain text" => return Kind::Code, + "title" => return Some(Kind::Title), + "quote" | "intense quote" => return Some(Kind::Quote), + "code" | "html preformatted" | "plain text" => return Some(Kind::Code), _ => {} } } + None + } + + fn para_kind(&self, st: &St) -> Kind { + if let Some(k) = self.style_kind(st) { + return k; + } if let Some(o) = st.outline.filter(|o| *o < 9) { return Kind::Heading((o + 1).min(6)); } @@ -1584,12 +1641,14 @@ impl Reader { p.fmt_change = Some(Box::new(model::FmtChange { mark: self.rev_mark(c.auth, c.date), old: (**old).clone() })); } // Headings, code, titles and quotes keep their style formatting implicit, in the text's - // formatting and in the formatting before a tracked change alike. + // formatting and in the formatting before a tracked change alike. The style is the + // file's when its name gave the kind (not an outline level on another style). + let base = if self.style_kind(&self.st) == Some(p.kind) { self.style_base(p.kind, self.st.style) } else { style_fmt(p.kind) }; for i in &mut p.inlines { if let Inline::Text(_, f) = i { - drop_style_formatting(p.kind, f); + drop_style_formatting(p.kind, &base, f); if let Some(c) = f.fmt_change.as_deref_mut() { - drop_style_formatting(p.kind, &mut c.old); + drop_style_formatting(p.kind, &base, &mut c.old); } } } @@ -1822,11 +1881,18 @@ impl Reader { return; } Dest::StyleSheet => { + let d = &mut self.style_def_cur; match w { "s" | "cs" | "ds" | "ts" => { self.style_cur = if w == "s" { pv } else { -1 - pv.saturating_abs() }; self.style_name.clear(); + *d = StyleDef::default(); } + "sbasedon" => d.based_on = Some(pv), + "b" => d.bold = Some(on), + "i" => d.italic = Some(on), + "fs" => d.size = (pv > 0).then(|| (pv as f32 / 2.0).clamp(1.0, 1638.0)), + "f" => d.font = Some(pv), _ => {} } return; @@ -2153,21 +2219,48 @@ fn styled(base: &Fmt, f: &Fmt) -> Fmt { e } -/// Clear from `f` the formatting a paragraph of kind `kind` gets from its style (as main's -/// import always has: any direct size, bold or code font there is taken as the style's). -fn drop_style_formatting(kind: Kind, f: &mut Fmt) { - if matches!(kind, Kind::Heading(_) | Kind::Code | Kind::Title) { - f.size = None; - if kind != Kind::Title { +/// A `\stylesheet` entry's character formatting; `None` where the entry doesn't set it. +#[derive(Clone, Copy, Debug, Default)] +struct StyleDef { + based_on: Option, + bold: Option, + italic: Option, + size: Option, + /// A `\fonttbl` index, named when it's used (the table may follow). + font: Option, +} + +/// Clear from `f` the formatting a paragraph of kind `kind` gets from its style, whose +/// formatting is `base`: a heading's or title's size and bold when they equal the style's, a +/// quote's italic, a code run's font when it's the style's (and, as before, its size and bold). +fn drop_style_formatting(kind: Kind, base: &Fmt, f: &mut Fmt) { + match kind { + Kind::Heading(_) | Kind::Title => { + if f.size.is_some() && f.size == base.size { + f.size = None; + } + if base.bold { + f.bold = false; + } + } + Kind::Code => { + f.size = None; f.bold = false; + let same_font = match (&f.font, &base.font) { + (Some(a), Some(b)) => a.eq_ignore_ascii_case(b), + (a, b) => a.is_none() && b.is_none(), + }; + if f.code == base.code && same_font { + f.code = false; + f.font = None; + } } - if kind == Kind::Code { - f.code = false; - f.font = None; + Kind::Quote => { + if base.italic { + f.italic = false; + } } - } - if kind == Kind::Quote { - f.italic = false; + Kind::Normal | Kind::Rule => {} } } @@ -3494,26 +3587,210 @@ mod tests { assert!(out.contains(&format!("{{\\*\\oldcprops \\f{mono}\\crauth1}}")), "{out}"); } + /// The text runs of `rtf`'s paragraphs, in order, with their formatting. + fn text_runs(rtf: &[u8]) -> Vec<(String, Fmt)> { + let f = parse(rtf).unwrap(); + f.blocks + .iter() + .filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }) + .flat_map(|p| p.inlines.iter()) + .filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.clone())) } else { None }) + .collect() + } + + /// The formatting of the run whose text is `t`. + fn run_fmt<'a>(runs: &'a [(String, Fmt)], t: &str) -> &'a Fmt { + runs.iter().find(|r| r.0 == t).map(|r| &r.1).unwrap_or_else(|| panic!("no run {t:?} in {runs:?}")) + } + #[test] fn heading_and_code_runs_take_their_style_formatting_on_import() { - // As on main: a heading's or code paragraph's runs drop their direct size, bold and - // (code) font; a size set by hand there isn't kept through RTF. - let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}{\f1 Arial;}}{\stylesheet{\s0 Normal;}{\s2 heading 2;}{\s9 Code;}} -\pard\s2\b\fs26 Head\par -\pard\s9\f1\b\fs21 code\par}"; - let f = parse(rtf).unwrap(); - let runs: Vec<&Fmt> = f - .blocks - .iter() - .filter_map(|b| match b { - FBlock::Para(p) => p.inlines.first().and_then(|i| if let Inline::Text(_, f) = i { Some(f) } else { None }), - _ => None, - }) - .collect(); - let head = runs.first().expect("heading run"); + // A stylesheet entry without formatting words (WordCraft's older files, minimal + // stylesheets): WordCraft's own heading and code formatting stand in for the style's. + let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}{\f1 Arial;}{\f2 Courier New;}}{\stylesheet{\s0 Normal;}{\s2 heading 2;}{\s9 Code;}} +\pard\s2\b\fs32 Head\par +\pard\s2\b\fs26 Small\par +\pard\s9\f2\b\fs21 code\par +\pard\s9\f1\fs21 arial\par}"; + let runs = text_runs(rtf); + let head = run_fmt(&runs, "Head"); assert_eq!((head.size, head.bold), (None, false)); - let code = runs.get(1).expect("code run"); + // 13 pt isn't heading 2's 16 pt: a size set by hand. + let small = run_fmt(&runs, "Small"); + assert_eq!((small.size, small.bold), (Some(13.0), false)); + let code = run_fmt(&runs, "code"); assert_eq!((code.size, code.bold, code.font.as_deref(), code.code), (None, false, None, false)); + // Arial isn't the code font: set by hand. + let arial = run_fmt(&runs, "arial"); + assert_eq!((arial.size, arial.font.as_deref(), arial.code), (None, Some("Arial"), false)); + } + + #[test] + fn heading_runs_are_compared_with_the_files_heading_style() { + // Word writes resolved values: a 13 pt bold Heading 2 has `\b\fs26` in its stylesheet + // entry and on its runs. Only what differs from the entry is direct formatting. + let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}}{\stylesheet{\ql \f0\fs24 Normal;} +{\s2\ql \keepn\outlinelevel1 \b\fs26 \sbasedon0 \snext0 heading 2;}{\s3\ql \keepn\b0\fs24 \sbasedon0 \snext0 heading 3;}} +\pard\s2\b\fs26 Head\par +\pard\s2\b\fs40 Big\par +\pard\s3\b\fs24 Bolded\par}"; + let runs = text_runs(rtf); + let head = run_fmt(&runs, "Head"); + assert_eq!((head.size, head.bold), (None, false)); + let big = run_fmt(&runs, "Big"); + assert_eq!((big.size, big.bold), (Some(20.0), false)); + // Heading 3 isn't bold in this file: the bold is the user's. + let bolded = run_fmt(&runs, "Bolded"); + assert_eq!((bolded.size, bolded.bold), (None, true)); + } + + #[test] + fn style_formatting_is_resolved_through_sbasedon() { + // LibreOffice's stylesheet: Heading 2 is based on Heading, which sets the boldness and + // font; Code is based on a style that sets the font. + let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Liberation Serif;}{\f1 Liberation Sans;}{\f2 Iosevka;}{\f3 Courier New;}} +{\stylesheet{\s0\f0\fs24 Normal;}{\s1\sbasedon0\snext2\f1\b0\fs28 Heading;}{\s2\sbasedon1\snext3\ql\fs32 Heading 2;} +{\s10\sbasedon0\f2\fs20 Source Base;}{\s9\sbasedon10 Code;}} +\pard\s2\f1\fs32 Plain\par +\pard\s2\f1\b\fs32 Bolded\par +\pard\s9\f2\fs20 base\par +\pard\s9\f3\fs20 courier\par}"; + let runs = text_runs(rtf); + let plain = run_fmt(&runs, "Plain"); + assert_eq!((plain.size, plain.bold), (None, false)); + // Heading isn't bold, so neither is Heading 2. + let bolded = run_fmt(&runs, "Bolded"); + assert_eq!((bolded.size, bolded.bold), (None, true)); + let base = run_fmt(&runs, "base"); + assert_eq!((base.size, base.font.as_deref(), base.code), (None, None, false)); + // Code's font is Iosevka here, so Courier New was set by hand. + let courier = run_fmt(&runs, "courier"); + assert_eq!((courier.size, courier.font.as_deref(), courier.code), (None, None, true)); + } + + #[test] + fn code_runs_in_the_files_code_font_have_no_direct_font() { + let rtf = + br"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}{\f1 Courier New;}{\f2 Arial;}}{\stylesheet{\s0 Normal;}{\s9\sbasedon0\sa0\f1\fs21 Code;}} +\pard\s9\f1\fs21 mono\par +\pard\s9\f2\fs21 arial\par}"; + let runs = text_runs(rtf); + let mono = run_fmt(&runs, "mono"); + assert_eq!((mono.size, mono.font.as_deref(), mono.code), (None, None, false)); + let arial = run_fmt(&runs, "arial"); + assert_eq!((arial.size, arial.font.as_deref(), arial.code), (None, Some("Arial"), false)); + } + + #[test] + fn hostile_sbasedon_chains_are_harmless() { + let mut chain = String::new(); + // s100 (heading 5) based on s101 … s139, the last one not bold: past the bound, so + // heading 5 takes WordCraft's bold. + for i in 100..140 { + let name = if i == 100 { "heading 5".to_string() } else { format!("x{i}") }; + let b0 = if i == 139 { "\\b0" } else { "" }; + chain.push_str(&format!("{{\\s{i}\\sbasedon{}{b0} {name};}}", i + 1)); + } + let rtf = format!( + r"{{\rtf1{{\stylesheet{{\s0 Normal;}}{{\s1\sbasedon1 heading 1;}}{{\s3\sbasedon4\b0 heading 3;}}{{\s4\sbasedon3 heading 4;}} +{{\s6\sbasedon-5 heading 6;}}{{\s2\sbasedon2147483647\fs-40 heading 2;}}{{\s-2147483648\sbasedon-2147483648 Title;}}{chain}}} +\pard\s1\b\fs40 one\par +\pard\s3\b\fs24 three\par +\pard\s4\b\fs24 four\par +\pard\s6\b\fs22 six\par +\pard\s2\b\fs32 two\par +\pard\s100\b\fs24 five\par +\pard\s-2147483648\fs56 title\par}}" + ); + let runs = text_runs(rtf.as_bytes()); + // Self-based heading 1: WordCraft's values. + assert_eq!((run_fmt(&runs, "one").size, run_fmt(&runs, "one").bold), (None, false)); + // A loop between heading 3 and 4: both take heading 3's `\b0`. + assert!(run_fmt(&runs, "three").bold); + assert!(run_fmt(&runs, "four").bold); + assert_eq!((run_fmt(&runs, "six").size, run_fmt(&runs, "six").bold), (None, false)); + assert_eq!((run_fmt(&runs, "two").size, run_fmt(&runs, "two").bold), (None, false)); + assert!(!run_fmt(&runs, "five").bold); + let _ = run_fmt(&runs, "title"); + } + + #[test] + fn a_word_365_heading_is_not_bold_so_a_bold_run_there_is_the_users() { + // Word 365's Heading 1 isn't bold: its entry sets the font and size but no `\b`, so a + // bold run, or a tracked change to or from bold, is the user's. + let rtf = br"{\rtf1\ansi{\fonttbl{\f1 Aptos;}{\f2 Aptos Display;}}{\colortbl;\red15\green71\blue97;} +{\stylesheet{\ql \f1\fs24 Normal;}{\s1\ql \keepn\outlinelevel0 \f2\fs40\cf1 \sbasedon0 \snext0 heading 1;}} +{\*\revtbl {Unknown;}{Alice;}} +\pard\s1\f2\fs40 Plain\par +\pard\s1\f2\b\fs40 Bold\par +\pard\s1\f2\fs40 {\b{\*\oldcprops \b0\crauth1}Bolded}{\b0{\*\oldcprops \b\crauth1}Unbolded}\par}"; + let runs = text_runs(rtf); + let plain = run_fmt(&runs, "Plain"); + assert_eq!((plain.size, plain.bold), (None, false)); + let bold = run_fmt(&runs, "Bold"); + assert_eq!((bold.size, bold.bold), (None, true)); + let bolded = run_fmt(&runs, "Bolded"); + let old = &bolded.fmt_change.as_deref().expect("change kept").old; + assert_eq!((bolded.bold, old.bold, old.size), (true, false, None)); + let unbolded = run_fmt(&runs, "Unbolded"); + let old = &unbolded.fmt_change.as_deref().expect("change kept").old; + assert_eq!((unbolded.bold, old.bold), (false, true)); + } + + #[test] + fn a_style_chain_without_a_size_has_rtfs_default_size() { + // The entries carry formatting words but no `\fs`: the style's size is RTF's 12 pt. + let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}}{\stylesheet{\s0\f0 Normal;}{\s2\sbasedon0\b heading 2;}} +\pard\s2\b\fs24 Twelve\par +\pard\s2\b\fs40 Twenty\par}"; + let runs = text_runs(rtf); + let twelve = run_fmt(&runs, "Twelve"); + assert_eq!((twelve.size, twelve.bold), (None, false)); + let twenty = run_fmt(&runs, "Twenty"); + assert_eq!((twenty.size, twenty.bold), (Some(20.0), false)); + } + + #[test] + fn a_code_paragraph_round_trips_without_a_direct_code_font() { + // WordCraft's Code entry has no `\f` (the code font is on the paragraph): the code font + // stands in for the style's. + let mut code = Para { kind: Kind::Code, ..Default::default() }; + code.push_text("let x", &Fmt::default()); + let flow = Flow { blocks: vec![FBlock::Para(code)], meta: Meta::default() }; + let rtf = export(&model::to_doc(&flow)); + let runs = text_runs(rtf.as_bytes()); + let f = run_fmt(&runs, "let x"); + assert_eq!((f.size, f.bold, f.code, f.font.as_deref()), (None, false, false, None), "{rtf}"); + } + + #[test] + fn a_stylesheet_entry_without_a_number_is_normal() { + // An entry without `\sN` is style 0; it doesn't replace the entry before it. + let rtf = br"{\rtf1\ansi{\stylesheet{\s2\b\fs26 heading 2;}{\b0\fs30 Normal;}} +\pard\s2\b\fs26 Head\par +\pard\s2\b\fs30 Other\par}"; + let runs = text_runs(rtf); + let head = run_fmt(&runs, "Head"); + assert_eq!((head.size, head.bold), (None, false)); + let other = run_fmt(&runs, "Other"); + assert_eq!((other.size, other.bold), (Some(15.0), false)); + } + + #[test] + fn a_heading_size_set_by_hand_survives_in_the_old_formatting() { + // Reject puts back a size set by hand: it isn't the file's heading size. + let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}}{\stylesheet{\ql \fs24 Normal;}{\s2\ql \b\fs26 \sbasedon0 \snext0 heading 2;}} +{\*\revtbl {Unknown;}{Alice;}} +\pard\s2 {\b\fs26{\*\oldcprops \fs40\crauth1}Shrunk}{\b\fs40{\*\oldcprops \fs26\crauth1}Grown}\par}"; + let runs = text_runs(rtf); + let shrunk = run_fmt(&runs, "Shrunk"); + assert_eq!(shrunk.size, None); + let old = &shrunk.fmt_change.as_deref().expect("change kept").old; + assert_eq!((old.size, old.bold), (Some(20.0), false)); + let grown = run_fmt(&runs, "Grown"); + assert_eq!(grown.size, Some(20.0)); + let old = &grown.fmt_change.as_deref().expect("change kept").old; + assert_eq!((old.size, old.bold), (None, false)); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ce6b489d9..d68488edf 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-11 · **Last updated:** 2026-10-19 · **Change:** trivial (text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** trivial (RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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; @@ -77,7 +77,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Hyphenation settings (`w:autoHyphenation`, `w:hyphenationZone`, `w:doNotHyphenateCaps`, `w:consecutiveHyphenLimit`) | ✅ | ✅ | Zone, capitals and limit kept and honoured by layout (#407) | | Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); note options (`w:pos`, `w:numFmt`, `w:numStart`, `w:numRestart`) round-trip in the settings and in each `w:sectPr` (#385); `commentsExtended` written; `commentsIds` (durable ids) and `commentsExtensible` (UTC dates) read and regenerated consistently with the written paragraph ids; `people.xml` carried verbatim | | Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 | -| Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF reads and writes character and paragraph changes (`\oldcprops`, `\oldpprops`, with the formatting the flow model knows, list membership included); as Word writes them, `\oldcprops` holds only the properties that changed (with `\crauth`/`\crdate` inside it) and `\oldpprops` the full old set from `\pard`; a run without a direct font or size has its paragraph style's written there (heading, Title, Quote, Code), Normal's as `\f0\fs24`; a size or bold set by hand on heading/code text isn't kept through RTF (as before), so Reject there gives the style's value; a formatting change on the paragraph mark itself and section, table, row and cell changes are dropped. ODT ignores them | +| Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF reads and writes character and paragraph changes (`\oldcprops`, `\oldpprops`, with the formatting the flow model knows, list membership included); as Word writes them, `\oldcprops` holds only the properties that changed (with `\crauth`/`\crdate` inside it) and `\oldpprops` the full old set from `\pard`; a run without a direct font or size has its paragraph style's written there (heading, Title, Quote, Code), Normal's as `\f0\fs24`; on import a heading's, Title's, Quote's or Code run's size, bold, italic or code font counts as the style's only when it equals the file's own stylesheet entry (`\b`, `\i`, `\fs`, `\f`, followed through `\sbasedon`; what the chain doesn't set is RTF's default, not bold and 12 pt, so Word 365's non-bold headings keep a bold set by hand; WordCraft's style values only where the chain sets no character formatting at all, and the code font where it sets no `\f`), so a size or bold set by hand there is kept, before and after a tracked change, and Reject gives it back; a formatting change on the paragraph mark itself and section, table, row and cell changes are dropped. ODT ignores them | | Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. `w:doNotTrackMoves` (Track moves off) round-trips; with it a tracked cut and paste records a plain deletion and insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion | | Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result; `ASK` and `SET` keep it but show nothing, as in Word (#1765). Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) | | Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Textual `REF` fields with `\h` export internal PDF links without refreshing cached text; existing enclosing links take precedence. `wordcraft-cli convert --update-fields` explicitly runs field updates before saving; ordinary conversion preserves caches and locked fields stay unchanged. Instruction-only paragraphs carry hidden marks instead of blank lines; an empty result paragraph keeps its line. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) | @@ -241,6 +241,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | 2026-10-14 | trivial | RTF export writes sections: `\sectd` formatting and `\sect` per section, round-tripping through the reader (gap 19) | | 2026-10-14 | trivial | `ASK` and `SET` fields keep their result but show nothing (#1765) | | 2026-10-16 | trivial | Strict OOXML line heights: `w:line="13.90pt"` under `lineRule="auto"` is read as Word renders it — through twips (13.90 × 20 = 278; the 1.1583 multiple), not as 240ths of a line — so a Word 2024 strict file opens at Word's line spacing and page count (#2329) | +| 2026-10-15 | trivial | RTF import reads each stylesheet entry's bold, italic, size and font (through `\sbasedon`; unset means RTF's default once the chain sets any) and keeps a heading's, Title's, Quote's or Code run's formatting set by hand, in the text and in the old formatting of a tracked change (gap 19) | | 2026-10-15 | trivial | RTF import's heading, Title and Code normalisation is main's again (direct size, bold and code font cleared); only the writer resolves old formatting to the style's values (gap 19) | | 2026-10-15 | trivial | RTF old formatting in heading, Title, Quote and Code paragraphs is written with the style's size and font, not RTF's 12 pt default; `\pard` inside `\oldcprops` no longer drops its words (gap 19) | | 2026-10-15 | trivial | RTF `\oldcprops` holds only the properties a tracked change altered, as Word for Mac writes it: read on top of the run's formatting (Reject no longer clears its font and size) with `\crauth`/`\crdate` inside the group, and written as the differences (gap 19) |