mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
RTF import: heading and code formatting compared with the file's own stylesheet (gap 19 follow-up) (#2236)
* gap 19: RTF import compares heading and code run formatting with the file's stylesheet Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * gap 19: review round 1: a style chain with formatting words is complete; unnumbered entries are Normal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Dmitrii Khlapov
Brandon Thomas
parent
b8debb3050
commit
4e2568e2da
+313
-36
@@ -1114,8 +1114,11 @@ struct Reader {
|
||||
colors: Vec<Option<Rgb>>,
|
||||
color_cur: (u8, u8, u8, bool),
|
||||
styles: HashMap<i32, String>,
|
||||
/// Each style's own character formatting, as its `\stylesheet` entry writes it.
|
||||
style_defs: HashMap<i32, StyleDef>,
|
||||
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<i32> = 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<Kind> {
|
||||
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::<u8>().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<i32>,
|
||||
bold: Option<bool>,
|
||||
italic: Option<bool>,
|
||||
size: Option<f32>,
|
||||
/// A `\fonttbl` index, named when it's used (the table may follow).
|
||||
font: Option<i32>,
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -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 `<blockquote>` 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 `<blockquote>` 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) |
|
||||
|
||||
Reference in New Issue
Block a user