Files
craft-libs/crates/rtf/src/doc/tests.rs
T
Dylan M. Taylor 7fe902292f craft-rtf: WordCraft's RTF reader and writer on craft-wordmodel and craft-flow (feature doc)
craft-rtf: WordCraft's RTF reader and writer on craft-wordmodel and craft-flow (feature doc)

* Add craft-wordmodel: WordCraft's document model without its editing code

WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX,
RTF, ODT and other text-format crates can read into and write from one model.
Module paths, type names and serde attributes are unchanged: an app that
re-exports the crate keeps its native file format.

Left in the app: range editing (edit.rs), IME text and equation editing. Not
model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count
check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks
moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs.

* craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs

The DOCX reader and writer walk an equation's runs. The walk and the child
accessors it uses were in WordCraft's equation editor (math_edit.rs), which
stays in the app; they are structure, not editing.

* craft-wordmodel: keep the document operations (edit.rs) with the model

The DOCX and RTF readers use them: alternative-format chunks are merged into
a document with adopt_blocks, and a reader formats ranges with format_range
and inserts objects. They operate on the document, not on a view, so they
belong beside it. The tests that drove them come back with them; the
paragraph walks stay in walk.rs and edit re-exports them.

base64 0.23, as WordCraft and craft-offcrypto use.

* Add craft-flow: the flow model the text formats map to, on craft-wordmodel

WordCraft's crates/formats/src/model (07f97737) moved as it is: Flow,
FBlock, Para, Inline, Fmt, tables and cells, pictures, to_doc and
from_doc, picture sniffing, colours, base64 and the Dropped report. Only
the paths changed (wordcraft_doc is craft_wordmodel); the report helpers
are public so the format crates can call them; the image re-encoding
helper stays with WordCraft's RTF writer.

* wip: WordCraft's RTF interpreter, as it is

* Take main's side of files outside craft-rtf

* craft-rtf: WordCraft's RTF reader and writer as they are at wordcraft 07f97737

crates/formats/src/rtf.rs and crates/formats/src/rtf/, unchanged and not yet
compiled (no module declares them), so the next commit shows only what the
move changes.

* craft-rtf: WordCraft's RTF reader and writer as craft_rtf::doc (feature doc)

On craft-flow and craft-wordmodel, with this crate's code pages
(craft-codepage), dates and escaping in place of WordCraft's copies, and
craft-metafile's metafile sniffing. A picture in a format RTF cannot carry is
re-encoded as PNG with a bounded decode (100 megapixels, 512 MiB), as
WordCraft's picture decoder bounded it.

The 76 tests moved with it.
2026-11-11 22:44:19 -05:00

1833 lines
93 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(clippy::type_complexity)] // The tests came from an app whose lints allow this.
use super::*;
fn texts(f: &Flow) -> Vec<String> {
f.blocks
.iter()
.map(|b| match b {
FBlock::Para(p) => format!("{:?}:{}", p.kind, p.text()),
FBlock::Table(t) => {
format!("table{}x{}:{}", t.rows.len(), t.cols(), t.rows.iter().flatten().map(|c| c.text()).collect::<Vec<_>>().join("|"))
}
})
.collect()
}
#[test]
fn basic_reader() {
let rtf = br#"{\rtf1\ansi{\fonttbl{\f0 Arial;}{\f1 Courier New;}}{\colortbl;\red255\green0\blue0;}{\stylesheet{\s0 Normal;}{\s1 heading 1;}}
{\info{\title My Doc}{\author Ann}}
\pard\s1 Head\par
\pard Plain {\b bold} \i it\i0 {\cf1 red} caf\'e9 \u8364? and\line next\tab x\par
{\*\unknown skip me}{\field{\*\fldinst{HYPERLINK "http://a.b"}}{\fldrslt{link}}}\par
\trowd\cellx100\cellx200 \pard\intbl a\cell b\cell\row
\pard after\par}"#;
let f = parse(rtf).unwrap();
assert_eq!(f.meta.title, "My Doc");
assert_eq!(f.meta.author, "Ann");
let t = texts(&f);
assert_eq!(t[0], "Heading(1):Head");
assert_eq!(t[1], "Normal:Plain bold it red café € and\nnext\tx");
assert_eq!(t[2], "Normal:link");
assert_eq!(t[3], "table1x2:a|b");
assert_eq!(t[4], "Normal:after");
let FBlock::Para(p) = &f.blocks[1] else { panic!() };
assert!(p.inlines.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "bold" && f.bold)));
assert!(p.inlines.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "red" && f.color == Some(Rgb(255, 0, 0)))));
let FBlock::Para(l) = &f.blocks[2] else { panic!() };
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
}
#[test]
fn stylesheet_names_take_unicode_escapes() {
// \u116?=t \u105?=i \u116?=t \u108?=l \u101?=e — how Word writes a non-Latin style name.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u116?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn stylesheet_names_take_negative_escapes() {
// 't' as a signed 16-bit escape: 116 - 65536 = -65420.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u-65420?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn stylesheet_names_honour_the_uc_fallback_count() {
// \uc2: two fallback bytes follow each \uN? — here a second `?`.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \uc2\u116??\u105??\u116??\u108??\u101??;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn a_malformed_hex_escape_leaves_no_stray_text() {
// A `\'` that is not followed by two hex digits is malformed: the escape itself goes, any
// hex digits of a truncated pair go with it, and everything else — non-hex bytes, `}` —
// is read as what it is. `\'gZ` keeps "gZ" as text; `\'4}` keeps the closing brace.
let rtf = br"{\rtf1\ansi \'gZok\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Normal:gZok");
// The bytes after a truncated pair must not swallow the group's closing brace or the next
// control word: the group and its destination state end where they should. Paragraph text
// alone would not show that: `survivor` reads the same inside the bold group, so the run's
// formatting is checked too.
let rtf = br"{\rtf1\ansi{\b \'4}survivor\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains("survivor")), "{:?}", texts(&f));
let survivor_bold = f.blocks.iter().find_map(|b| match b {
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
Inline::Text(t, fmt) if t.contains("survivor") => Some(fmt.bold),
_ => None,
}),
FBlock::Table(_) => None,
});
assert_eq!(survivor_bold, Some(false), "survivor must not be inside the bold group: {:?}", f.blocks);
// The truncated pair's hex digit goes with the escape: no stray "4" is left in the text.
assert!(!texts(&f).iter().any(|t| t.contains('4')), "{:?}", texts(&f));
// A leading `+` is not a hex digit: `\'+4` is no escape: it stays text, not the byte 0x04.
let rtf = br"{\rtf1\ansi \'+4z\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Normal:+4z");
let rtf = br"{\rtf1\ansi{\'}}after\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains("after")), "{:?}", texts(&f));
// A hex digit that starts a valid pair with the byte after it is part of the escape:
// \'4} reads '4' as the escape's byte and leaves `}` to close the group.
let rtf = br"{\rtf1\ansi\'4}x\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains('x') && !t.contains('}')), "{:?}", texts(&f));
}
#[test]
fn unicode_fallback_uses_the_declared_count() {
for (rtf, expected) in [
(br"{\rtf1\uc18\u8804less than or equal rest}".as_slice(), "≤ rest"),
(br"{\rtf1\uc10\u233\'30\'31\'32\'33\'34\'35\'36\'37\'38\'39 rest}", "é rest"),
(br"{\rtf1\uc2\u8804\:ab rest}", "≤b rest"),
(br"{\rtf1\uc18\u8804\:12345678901234567 rest}", "≤ rest"),
(br"{\rtf1\uc1\u8804\:ab rest}", "≤ab rest"),
(br"{\rtf1\uc0\u8804\:ab rest}", "≤ab rest"),
(br"{\rtf1\uc9\u233abcdefgh\par VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc1\u233\pard VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc1\u233\cell VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc1\u233\row VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc0\u233\par VISIBLE}", "é\nVISIBLE"),
(b"{\\rtf1\\uc9\\u233abcdefgh\\\nVISIBLE}", "éVISIBLE"),
(b"{\\rtf1\\uc9\\u233abcdefgh\\\rVISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc9\u233abcdefgh\*VISIBLE}", "éVISIBLE"),
(b"{\\rtf1\\uc0\\u233\\\nVISIBLE}", "é\nVISIBLE"),
(b"{\\rtf1\\uc0\\u233\\\rVISIBLE}", "é\nVISIBLE"),
(br"{\rtf1\uc0\u233\*VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc0\u233X}", "éX"),
(br"{\rtf1\uc1\u233?X}", "éX"),
(br"{\rtf1{\uc2147483647\u233?}X}", "éX"),
(br"{\rtf1\uc-1\u233X}", "éX"),
] {
let doc = import(rtf).unwrap();
assert_eq!(doc.plain_text(craft_wordmodel::StoryRef::Body).trim(), expected, "{rtf:?}");
}
}
#[test]
fn unicode_fallback_info_words_preserve_following_metadata() {
for (rtf, title, body) in [
(br"{\rtf1{\info{\title\uc9\u233abcdefgh\info X}{\author Alice}} Text}".as_slice(), "éX", "Text"),
(br"{\rtf1{\info{\title\uc1\u233\info X}{\author Alice}} Text}", "éX", "Text"),
(br"{\rtf1{\info{\title\uc0\u233X}{\author Alice}} Text}", "éX", "Text"),
(br"{\rtf1\uc0\u233{\info{\title Plain}{\author Alice}}Text}", "Plain", "éText"),
] {
let doc = import(rtf).unwrap();
assert_eq!(doc.core.title, title, "{rtf:?}");
assert_eq!(doc.core.creator, "Alice", "{rtf:?}");
assert_eq!(doc.plain_text(craft_wordmodel::StoryRef::Body).trim(), body, "{rtf:?}");
}
}
#[test]
fn unicode_fallback_in_revision_authors_counts_control_words() {
for (rtf, expected) in [
(br"{\rtf1{\*\revtbl{Unknown;}{\uc9\u233abcdefgh\par Zed;}}{\revised\revauth1 text}}".as_slice(), "éZed"),
(br"{\rtf1{\*\revtbl{Unknown;}{\uc0\u233\par Zed;}}{\revised\revauth1 text}}", "éZed"),
] {
let doc = import(rtf).unwrap();
assert_eq!(doc.revisions.first().map(|r| r.author.as_str()), Some(expected), "{rtf:?}");
}
}
#[test]
fn paragraph_borders() {
// A heading with a grey rule under it, as hh.ru résumés write their section titles.
let rtf = br"{\rtf1\ansi{\colortbl;\red0\green0\blue0;\red216\green216\blue216;}
\pard\brdrb\brdrs\brdrw10\brdrcf2 Section\par
\pard Body\par
\pard\brdrt\brdrdb\brdrl\brdrs\brdrr\brdrs Boxed\par
\pard\brdrb\brdrs\par}";
let f = parse(rtf).unwrap();
let paras: Vec<&Para> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect();
let b = paras[0].borders.unwrap();
let bottom = b.bottom.unwrap();
assert_eq!((bottom.width, bottom.color), (0.5, Some(Rgb(216, 216, 216))));
assert!(b.top.is_none());
assert_eq!(paras[0].kind, Kind::Normal);
// \pard resets them.
assert!(paras[1].borders.is_none());
let b = paras[2].borders.unwrap();
assert_eq!(b.top.map(|x| x.style), Some(craft_wordmodel::props::BorderStyle::Double));
assert!(b.left.is_some() && b.right.is_some() && b.bottom.is_none());
// An empty paragraph with only a bottom border is still a rule.
assert_eq!(paras[3].kind, Kind::Rule);
// The document's paragraph carries them.
let d = import(rtf).unwrap();
let first = d.body.iter().find_map(|b| if let craft_wordmodel::Block::Para(p) = &**b { Some(p) } else { None }).unwrap();
assert!(first.props.borders.and_then(|b| b.bottom).is_some());
// Paragraph borders round-trip through export and import.
let exported = export(&d);
assert!(exported.contains("\\brdrb\\brdrs\\brdrw10\\brdrcf1"), "{exported}");
let back = import(exported.as_bytes()).unwrap();
let first = back.body.iter().find_map(|b| if let craft_wordmodel::Block::Para(p) = &**b { Some(p) } else { None }).unwrap();
let b = first.props.borders.unwrap().bottom.unwrap();
assert_eq!((b.style, b.width, b.color), (craft_wordmodel::props::BorderStyle::Single, 0.5, Some(Rgb(216, 216, 216))));
}
#[test]
fn headers_and_footers() {
let rtf = br"{\rtf1\ansi\titlepg
{\header \pard\qr Running head\par}
{\headerf \pard First head {\b bold}\par}
{\footer \pard Page \chpgn\par}
\pard Body text\par
\trowd\cellx2000\pard\intbl cell\cell\row
\pard after\par}";
let d = import(rtf).unwrap();
let s = &d.last_section;
assert!(s.title_page);
let text = |id: Option<u32>| {
let p = id.and_then(|id| d.parts.get(&id)).unwrap();
p.blocks
.iter()
.filter_map(|b| if let craft_wordmodel::Block::Para(p) = &**b { Some(p.text.clone()) } else { None })
.collect::<Vec<_>>()
.join("|")
};
assert_eq!(text(s.headers.default), "Running head");
assert_eq!(text(s.headers.first), "First head bold");
assert!(text(s.footers.default).starts_with("Page"));
assert!(s.footers.first.is_none() && s.headers.even.is_none());
// The body keeps only its own text, and its table survives the header groups.
let f = parse(rtf).unwrap();
let t = texts(&f);
assert_eq!(t, ["Normal:Body text", "table1x1:cell", "Normal:after"]);
// Without \titlepg there is no separate first page.
let d = import(br"{\rtf1\ansi{\headerf x\par} y\par}").unwrap();
assert!(!d.last_section.title_page && d.last_section.headers.first.is_some());
// An unterminated header still ends, and the body text after it isn't lost.
assert!(import(br"{\rtf1\ansi{\header x").is_ok());
}
#[test]
fn paragraph_spacing_and_indents() {
use craft_wordmodel::props::LineSpacing;
let rtf = br"{\rtf1\ansi
\pard\sb120\sa150\sl360\slmult1\li720\ri360\fi-360 a\par
\pard\sl-280 b\par
\pard\sl300\slmult0 c\par
\pard d\par}";
let f = parse(rtf).unwrap();
let sp: Vec<model::Spacing> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.spacing) } else { None }).collect();
assert_eq!(sp[0].before, Some(6.0));
assert_eq!(sp[0].after, Some(7.5));
assert_eq!(sp[0].line, Some(LineSpacing::Multiple(1.5)));
assert_eq!((sp[0].left, sp[0].right, sp[0].first), (Some(36.0), Some(18.0), Some(-18.0)));
assert_eq!(sp[1].line, Some(LineSpacing::Exactly(14.0)));
assert_eq!(sp[2].line, Some(LineSpacing::AtLeast(15.0)));
// \pard resets them.
assert_eq!(sp[3], model::Spacing::default());
// The document's defaults are RTF's: single, no space after; the paragraph's own win.
let d = import(rtf).unwrap();
assert_eq!(d.styles.default_para.space_after, Some(0.0));
assert_eq!(d.styles.default_para.line_spacing, Some(LineSpacing::Multiple(1.0)));
let first = d.body.iter().find_map(|b| if let craft_wordmodel::Block::Para(p) = &**b { Some(p) } else { None }).unwrap();
assert_eq!(first.props.space_after, Some(7.5));
assert_eq!(first.props.indent_first, Some(-18.0));
}
#[test]
fn default_font_is_deff_or_font_zero() {
// Text that never selects a font is in the default font: \deff, else font 0.
let d = import(br"{\rtf1\ansi{\fonttbl{\f0\fnil Times New Roman Cyr;}{\f1 Arial;}}\plain x\par}").unwrap();
assert_eq!(d.styles.default_chr.font.as_deref(), Some("Times New Roman Cyr"));
let d = import(br"{\rtf1\ansi\deff1{\fonttbl{\f0 Times New Roman;}{\f1 Arial;}} x\par}").unwrap();
assert_eq!(d.styles.default_chr.font.as_deref(), Some("Arial"));
// No font table: our default stays.
let d = import(br"{\rtf1\ansi x\par}").unwrap();
assert_eq!(d.styles.default_chr.font, craft_wordmodel::StyleSheet::builtin().default_chr.font);
}
#[test]
fn unicode_escapes_in_font_names() {
// Ordinary fallback, hex fallback and \uc0 all give "Café Sans"; the fallback is not kept.
for name in [r"Caf\u233? Sans", r"Caf\u233\'e9 Sans", r"\uc0 Caf\u233 Sans", r"\uc2 Caf\u233\'e9\'e9 Sans"] {
let rtf = format!(r"{{\rtf1\ansi\deff0{{\fonttbl{{\f0 {name};}}{{\f1 \u923?ambda;}}}}\plain x {{\f1 y}}\par}}");
let d = import(rtf.as_bytes()).unwrap();
assert_eq!(d.styles.default_chr.font.as_deref(), Some("Café Sans"), "{name}");
// A body run in a Unicode-named font keeps that name.
let f = parse(rtf.as_bytes()).unwrap();
let y_font = f.blocks.iter().find_map(|b| match b {
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
Inline::Text(t, fmt) if t.trim() == "y" => fmt.font.clone(),
_ => None,
}),
FBlock::Table(_) => None,
});
assert_eq!(y_font.as_deref(), Some("Λambda"), "{name}");
// Our export writes font names as `\uN?`; reading it back gives the same name.
let back = import(export(&d).as_bytes()).unwrap();
assert_eq!(back.styles.default_chr.font.as_deref(), Some("Café Sans"), "{name}");
}
}
#[test]
fn page_size_and_margins() {
let d = import(br"{\rtf1\ansi\paperw11906\paperh16838\margl1134\margr850\margt1134\margb1134 x\par}").unwrap();
let s = &d.last_section;
assert!((s.page_w - 595.3).abs() < 0.1 && (s.page_h - 841.9).abs() < 0.1, "{} x {}", s.page_w, s.page_h);
assert!((s.margin_left - 56.7).abs() < 0.1 && (s.margin_right - 42.5).abs() < 0.1);
assert!(!s.landscape);
let d = import(br"{\rtf1\ansi\paperw11906\paperh16838\landscape x\par}").unwrap();
assert!(d.last_section.landscape && d.last_section.page_w > d.last_section.page_h);
let exported = export(&d);
assert!(exported.contains("\\landscape"), "exported RTF must include \\landscape: {exported}");
// Nonsense keeps Word's defaults.
let d = import(br"{\rtf1\ansi\paperw0\margl99999 x\par}").unwrap();
assert_eq!(d.last_section, craft_wordmodel::SectionProps::default());
}
#[test]
fn table_grid_from_cell_edges_and_borders() {
// Rows whose cells don't line up share one grid: edges 2000, 3000, 9000.
let rtf = br"{\rtf1\ansi
\trowd\cellx2000\cellx9000 \pard\intbl a\cell b\cell\row
\trowd\cellx3000\cellx9000 \pard\intbl c\cell d\cell\row
\pard after\par}";
let f = parse(rtf).unwrap();
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
assert_eq!(t.widths, [100.0, 50.0, 300.0]);
assert_eq!(t.rows[0].iter().map(|c| c.colspan).collect::<Vec<_>>(), [1, 2]);
assert_eq!(t.rows[1].iter().map(|c| c.colspan).collect::<Vec<_>>(), [2, 1]);
assert_eq!(t.cols(), 3);
// RTF tables have no grid style: each cell draws the borders it names, nothing else.
assert!(t.borderless);
assert!(t.rows.iter().flatten().all(|c| c.borders.is_none()));
// A rule under a cell (a letterhead's line): the bottom side only, its width and colour.
let f = parse(br"{\rtf1\ansi{\colortbl;\red174\green174\blue174;}\trowd\clbrdrb\brdrs\brdrw15\brdrcf1\cellx2000\pard\intbl a\cell\row}").unwrap();
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
let b = t.rows[0][0].borders.unwrap();
assert!(b.top.is_none() && b.left.is_none() && b.right.is_none());
let bottom = b.bottom.unwrap();
assert_eq!((bottom.style, bottom.width, bottom.color), (craft_wordmodel::props::BorderStyle::Single, 0.75, Some(Rgb(174, 174, 174))));
// Every side, in different styles, per cell.
let f = parse(
br"{\rtf1\ansi\trowd\clbrdrt\brdrdb\clbrdrl\brdrs\brdrw10\clbrdrb\brdrdot\clbrdrr\brdrth\cellx2000\cellx4000\pard\intbl a\cell b\cell\row}",
)
.unwrap();
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
let b = t.rows[0][0].borders.unwrap();
use craft_wordmodel::props::BorderStyle as S;
assert_eq!(
[b.top.map(|x| x.style), b.left.map(|x| x.style), b.bottom.map(|x| x.style), b.right.map(|x| x.style)],
[Some(S::Double), Some(S::Single), Some(S::Dotted), Some(S::Thick)]
);
assert_eq!(b.left.map(|x| x.width), Some(0.5));
assert!(t.rows[0][1].borders.is_none(), "the second cell names no borders");
// A border side set to none isn't a border.
let f = parse(br"{\rtf1\ansi\trowd\clbrdrl\brdrnone\cellx2000\pard\intbl a\cell\row}").unwrap();
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
assert!(t.rows[0][0].borders.is_none());
// The document's cells carry them.
let d = import(br"{\rtf1\ansi\trowd\clbrdrb\brdrs\cellx2000\pard\intbl a\cell\row}").unwrap();
let t = d.body.iter().find_map(|b| if let craft_wordmodel::Block::Table(t) = &**b { Some(t) } else { None }).unwrap();
assert!(t.props.style.is_none());
assert!(t.rows[0].cells[0].props.borders.and_then(|b| b.bottom).is_some());
// Borderless tables export without cell borders and round-trip borderless.
let d = import(br"{\rtf1\ansi\trowd\cellx2000\pard\intbl a\cell\row}").unwrap();
let exported = export(&d);
assert!(!exported.contains("\\clbrdr"), "{exported}");
let back = import(exported.as_bytes()).unwrap();
let tb = back.body.iter().find_map(|b| if let craft_wordmodel::Block::Table(t) = &**b { Some(t) } else { None }).unwrap();
assert!(tb.props.style.is_none());
assert!(tb.rows[0].cells[0].props.borders.is_none());
// Cell borders round-trip through export and import.
let d =
import(br"{\rtf1\ansi{\colortbl;\red174\green174\blue174;}\trowd\clbrdrb\brdrdb\brdrw15\brdrcf1\cellx2000\pard\intbl a\cell\row}").unwrap();
let exported = export(&d);
assert!(exported.contains("\\clbrdrb\\brdrdb\\brdrw15\\brdrcf1"), "{exported}");
let back = import(exported.as_bytes()).unwrap();
let tb = back.body.iter().find_map(|b| if let craft_wordmodel::Block::Table(t) = &**b { Some(t) } else { None }).unwrap();
let b = tb.rows[0].cells[0].props.borders.unwrap().bottom.unwrap();
assert_eq!((b.style, b.width, b.color), (craft_wordmodel::props::BorderStyle::Double, 0.75, Some(Rgb(174, 174, 174))));
// The table sizes to the document's page, not Letter.
let d = import(br"{\rtf1\ansi\paperw11906\margl1134\margr1134\trowd\cellx4818\cellx9638\pard\intbl a\cell b\cell\row}").unwrap();
let t = d.body.iter().find_map(|b| if let craft_wordmodel::Block::Table(t) = &**b { Some(t) } else { None }).unwrap();
assert_eq!(t.grid, [240.9, 241.0]);
}
#[test]
fn code_pages_from_ansicpg_and_font_charsets() {
// The document's code page: Russian RTF as accounting systems write it.
let f =
parse(br"{\rtf1\ansi\ansicpg1251\deff0{\fonttbl{\f0 Times New Roman;}}\f0 \'cf\'f0\'e8\'e2\'e5\'f2, \'b8\'eb\'ea\'e0 \'b9 5\par}").unwrap();
assert_eq!(texts(&f), ["Normal:Привет, ёлка № 5"]);
// Font charsets switch inside a Windows-1252 document: Western, Cyrillic, Central
// European, then back to the first font.
let f = parse(
br"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0 Arial;}{\f1\fcharset204 Arial CYR;}{\f2\fcharset238 Arial CE;}}\f0 caf\'e9 {\f1 \'c4\'e0} \f2 \'a3\'f3d\'9f \f0 na\'efve\par}",
)
.unwrap();
assert_eq!(texts(&f), ["Normal:café Да Łódź naïve"]);
// The default font's charset applies before any \f; an explicit \cpg wins over \fcharset.
let f = parse(br"{\rtf1\ansi\deff1{\fonttbl{\f1\fcharset204 Times;}{\f2\fcharset0\cpg1253 Greek;}}\'cc\'e8\'f0 \f2 \'c1\'e2\par}").unwrap();
assert_eq!(texts(&f), ["Normal:Мир Αβ"]);
// Raw 8-bit text (not escaped) follows the same code page.
let f = parse(b"{\\rtf1\\ansi\\ansicpg1251 \xc4\xee\xec\\par}").unwrap();
assert_eq!(texts(&f), ["Normal:Дом"]);
// #1653: a Macintosh document (LibreOffice's fdo85889-mac.rtf) is Mac OS Roman, and so is
// a font with the Mac charset in a Windows document.
let f = parse(br"{\rtf1\mac \deff0{\fonttbl{\f0\fmodern Helvetica;}}\pard\f0\fs20 \'f1\'f2\'f3\par}").unwrap();
assert_eq!(texts(&f), ["Normal:ÒÚÛ"]);
let f = parse(br"{\rtf1\ansi\deff0{\fonttbl{\f0 Arial;}{\f1\fcharset77 Geneva;}}caf\'e9 {\f1 caf\'8e \'d2ok\'d3}\par}").unwrap();
assert_eq!(texts(&f), ["Normal:café café “ok”"]);
// \fcharset255 is the standard OEM character set (CP437).
let f = parse(br"{\rtf1\ansi\deff0{\fonttbl{\f0 Arial;}{\f1\fcharset255 Terminal;}}caf\'e9 {\f1 caf\'82}\par}").unwrap();
assert_eq!(texts(&f), ["Normal:café café"]);
// DOS documents: `\pc` is code page 437, `\pca` 850; a font can name one with `\cpg`.
let f = parse(br"{\rtf1\pc\deff0{\fonttbl{\f0 Courier;}}caf\'82 \'a0 la carte\par}").unwrap();
assert_eq!(texts(&f), ["Normal:café á la carte"]);
let f = parse(br"{\rtf1\pca\deff0{\fonttbl{\f0 Courier;}{\f1\cpg437 Courier;}}\'9d {\f1 \'9d}\par}").unwrap();
assert_eq!(texts(&f), ["Normal:Ø ¥"]);
// A code page number out of range is ignored, not truncated to another one.
let f = parse(br"{\rtf1\ansi\ansicpg65973 caf\'e9\par}").unwrap();
assert_eq!(texts(&f), ["Normal:café"]);
// The OEM charset (255) is DOS code page 437, like 254.
let f = parse(br"{\rtf1\ansi\deff0{\fonttbl{\f0\fcharset255 Courier New;}}\f0 caf\'82\par}").unwrap();
assert_eq!(texts(&f), ["Normal:café"]);
}
/// #1653: the East Asian code pages (932 Shift-JIS, 936 GBK, 949 UHC, 950 Big5) decode their
/// bytes as a run, so a lead byte and its trail byte decode together, escaped or raw.
#[test]
fn east_asian_multibyte_code_pages() {
// Japanese `\ansicpg932` (CP932: 日 = 93 fa, 本 = 96 7b, 語 = 8c ea).
let f = parse(br"{\rtf1\ansi\ansicpg932 \'93\'fa\'96\'7b\'8c\'ea\par}").unwrap();
assert_eq!(texts(&f), ["Normal:日本語"]);
// Raw 8-bit text (not `\'hh`), and raw bytes mixed with one escaped byte: RTF can't carry a
// raw `{`, `}` or `\`, so 本's trail byte 7b is written `\'7b` here.
let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x8a\xbf\x8e\x9a\\par}").unwrap();
assert_eq!(texts(&f), ["Normal:漢字"]);
let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93\xfa\x96\\'7b\x8c\xea\\par}").unwrap();
assert_eq!(texts(&f), ["Normal:日本語"]);
// A font names the code page with `\fcharset128` or with `\cpg932`, and `\f0` selects it.
let f = parse(br"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\fcharset128 MS Mincho;}}\f0 \'93\'fa\'96\'7b\'8c\'ea\par}").unwrap();
assert_eq!(texts(&f), ["Normal:日本語"]);
let f = parse(br"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\cpg932 MS Mincho;}}\f0 \'93\'fa\'96\'7b\'8c\'ea\par}").unwrap();
assert_eq!(texts(&f), ["Normal:日本語"]);
// Raw 8-bit bytes in the font table (the font's name) decode in the document's code page.
let d = import(b"{\\rtf1\\ansi\\ansicpg932\\deff0{\\fonttbl{\\f0\\fcharset128 \x96\xbe\x92\xa9;}}\\f0 x\\par}").unwrap();
assert_eq!(d.styles.default_chr.font.as_deref(), Some("明朝"));
// Korean `\ansicpg949` (UHC: 한국어 = c7 d1 b1 b9 be ee).
let f = parse(br"{\rtf1\ansi\ansicpg949 \'c7\'d1\'b1\'b9\'be\'ee\par}").unwrap();
assert_eq!(texts(&f), ["Normal:한국어"]);
// Simplified Chinese `\ansicpg936` (GBK: 中文 = d6 d0 ce c4).
let f = parse(br"{\rtf1\ansi\ansicpg936 \'d6\'d0\'ce\'c4\par}").unwrap();
assert_eq!(texts(&f), ["Normal:中文"]);
// Traditional Chinese `\ansicpg950` (Big5: 中文 = a4 a4 a4 e5).
let f = parse(br"{\rtf1\ansi\ansicpg950 \'a4\'a4\'a4\'e5\par}").unwrap();
assert_eq!(texts(&f), ["Normal:中文"]);
// `\uN?` escapes still decode alongside multibyte text (`\u26412?` is 本, its `?` fallback
// skipped by `\uc1`).
let f = parse(br"{\rtf1\ansi\ansicpg932 \'93\'fa\u26412?\'8c\'ea x\par}").unwrap();
assert_eq!(texts(&f), ["Normal:日本語 x"]);
// An invalid sequence decodes as U+FFFD, as Word writes it, and doesn't lose the rest.
let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93\\par after\\par}").unwrap();
assert_eq!(texts(&f), ["Normal:\u{FFFD}", "Normal:after"]);
let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93\xfa\x93 x\x96\x8c\\par}").unwrap();
assert_eq!(texts(&f), ["Normal:日\u{FFFD} x膜"]);
}
/// The decoder never sniffs a BOM: the code page is named by `\ansicpg`, `\cpg` or `\fcharset`,
/// so a run that starts `EF BB BF` is a character pair and a lead byte, not a UTF-8 BOM (which
/// would switch the rest of the document to UTF-8 and garble it).
#[test]
fn a_bom_like_byte_triple_is_text_in_a_multibyte_code_page() {
// GBK EF BB = 锘, then BF B5 = 康; UHC EF BB = 癤, then BF C0 = 오; Big5 EF BB = 嚜, then
// BF 40 = 濃.
let f = parse(br"{\rtf1\ansi\ansicpg936 \'ef\'bb\'bf\'b5\par}").unwrap();
assert_eq!(texts(&f), ["Normal:锘康"]);
let f = parse(br"{\rtf1\ansi\ansicpg949 \'ef\'bb\'bf\'c0\par}").unwrap();
assert_eq!(texts(&f), ["Normal:癤오"]);
let f = parse(br"{\rtf1\ansi\ansicpg950 \'ef\'bb\'bf\'40\par}").unwrap();
assert_eq!(texts(&f), ["Normal:嚜濃"]);
}
/// A multibyte run longer than any fixed buffer keeps every character: the streaming decoder
/// holds a lead byte and its trail byte together across decode steps instead of cutting the
/// character at a buffer size (the review's finding on the byte-capped flush).
#[test]
fn a_long_multibyte_run_never_splits_a_character() {
let mut bytes = b"{\\rtf1\\ansi\\ansicpg932 ".to_vec();
bytes.push(b'A');
// 2048 × あ (CP932 82 a0) is 4096 bytes after the one ASCII byte: a fixed 4096-byte buffer
// would flush with the last あ's lead byte in one step and its trail byte in the next.
for _ in 0..2048 {
bytes.extend_from_slice(&[0x82, 0xA0]);
}
bytes.extend_from_slice(b"\\par}");
let f = parse(&bytes).unwrap();
let expected: String = std::iter::once('A').chain((0..2048).map(|_| 'あ')).collect();
assert_eq!(texts(&f), [format!("Normal:{expected}")]);
}
/// A lead byte with no trail byte at the end of the document becomes U+FFFD, as Word writes a
/// malformed sequence — the streaming decoder is finalised, not left holding it.
#[test]
fn an_incomplete_lead_byte_at_the_end_is_replacement() {
let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93}").unwrap();
assert_eq!(texts(&f), ["Normal:\u{FFFD}"]);
}
/// #1653: a code page switch (`\f`, `\fcharset`, `\cpg`, `\ansicpg`), a group end and the end
/// of the document decode the multibyte bytes read so far, before the new code page applies.
#[test]
fn multibyte_code_page_switches_flush_pending_bytes() {
// Japanese (932) → ASCII → Cyrillic (`\fcharset204` with `\cpg1251`): each run decodes in
// its own code page, the last bytes before the switch included.
let f = parse(
br"{\rtf1\ansi\ansicpg932\deff0{\fonttbl{\f0 MS Mincho;}{\f1\fcharset204\cpg1251 Arial CYR;}}\f0 \'93\'fa\'96\'7b\'8c\'ea mixed \f1 \'cf\'f0\'e8\'e2\'e5\'f2\par}",
)
.unwrap();
assert_eq!(texts(&f), ["Normal:日本語 mixed Привет"]);
// A group end flushes: the group's 932 bytes decode before the outer 1252 text.
let f = parse(br"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0 Arial;}{\f1\fcharset128 MS Mincho;}}\f0 {\f1 \'93\'fa\'96\'7b\'8c\'ea}caf\'e9\par}")
.unwrap();
assert_eq!(texts(&f), ["Normal:日本語café"]);
// An `\ansicpg` switch mid-document flushes too.
let f = parse(br"{\rtf1\ansi\ansicpg932 \'93\'fa\ansicpg1251 \'cf\'f0\'e8\par}").unwrap();
assert_eq!(texts(&f), ["Normal:日При"]);
// A run left open at the end of the document isn't lost.
let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93\xfa").unwrap();
assert_eq!(texts(&f), ["Normal:日"]);
}
/// A Windows-1252 document (the default) reads exactly as before: high bytes one at a time.
#[test]
fn windows_1252_text_is_unchanged() {
let f = parse(b"{\\rtf1\\ansi\\ansicpg1252 caf\xe9 na\xefve \x93quoted\x94 \\'80 5\\'b0\\par}").unwrap();
assert_eq!(texts(&f), ["Normal:café naïve “quoted” € 5°"]);
// No `\ansicpg`: the default is 1252; the byte values of an East Asian code page keep
// their 1252 meaning.
let f = parse(b"{\\rtf1\\ansi \xe9\\'92\\'93 \\'fa\\par}").unwrap();
assert_eq!(texts(&f), ["Normal:é’“ ú"]);
}
#[test]
fn shppict_imports_one_image_and_skips_legacy_duplicate() -> Result<(), String> {
let pixels = image::RgbaImage::from_pixel(1, 1, image::Rgba([40, 80, 160, 255]));
let mut png = Vec::new();
pixels.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| e.to_string())?;
let hex: String = png.iter().map(|b| format!("{b:02x}")).collect();
let pict = format!("{{\\pict\\pngblip\\picwgoal1440\\pichgoal720 {hex}}}");
// Also use a supported PNG as the legacy duplicate: it must still be skipped.
for legacy in [format!("{{\\pict\\wmetafile8 {hex}}}"), pict.clone()] {
for picture in [format!("{{\\*\\shppict{pict}}}"), pict.clone()] {
let f = parse(format!("{{\\rtf1\\ansi Before{picture}{{\\nonshppict{legacy}}}After\\par}}").as_bytes())?;
let images: Vec<_> = f
.blocks
.iter()
.filter_map(|b| match b {
FBlock::Para(p) => Some(&p.inlines),
FBlock::Table(_) => None,
})
.flatten()
.filter_map(|i| match i {
Inline::Image(img) => Some(img),
_ => None,
})
.collect();
assert_eq!(images.len(), 1, "{picture}");
let img = images.first().ok_or("missing picture")?;
assert_eq!(img.data.as_ref(), &png);
assert_eq!(img.ext, "png");
assert_eq!((img.w, img.h), (72.0, 36.0));
assert_eq!(texts(&f), vec!["Normal:BeforeAfter"]);
}
}
// Recognizing shppict must not resurrect pictures inside skipped destinations.
for dest in ["nonshppict", "unknown"] {
let f = parse(format!("{{\\rtf1{{\\*\\{dest}{{\\*\\shppict{pict}}}}}Before\\par}}").as_bytes())?;
assert_eq!(texts(&f), vec!["Normal:Before"]);
assert!(f.blocks.iter().all(|b| match b {
FBlock::Para(p) => p.inlines.iter().all(|i| !matches!(i, Inline::Image(_))),
FBlock::Table(_) => false,
}));
}
Ok(())
}
/// #4259: `\wmetafile8` and `\emfblip` pictures are kept as WMF/EMF media (they were dropped),
/// sized from `\picwgoal`/`\pichgoal` and `\picscalex`/`\picscaley`, else from `\picw`/`\pich`
/// (0.01 mm for a metafile). As Word writes a floating picture: the `\shprslt` copy is read.
#[test]
fn metafile_pictures_are_imported() -> Result<(), String> {
// A plain WMF: the 9-word header (type 1, version 0x300, 12 words, max record 3), then EOF.
let wmf: Vec<u8> = [1u16, 9, 0x300, 12, 0, 0, 3, 0, 0, 3, 0, 0].iter().flat_map(|w| w.to_le_bytes()).collect();
let hex: String = wmf.iter().map(|b| format!("{b:02x}")).collect();
let images = |rtf: String| -> Result<Vec<model::Img>, String> {
let f = parse(rtf.as_bytes())?;
Ok(f.blocks
.iter()
.filter_map(|b| match b {
FBlock::Para(p) => Some(p.inlines.clone()),
FBlock::Table(_) => None,
})
.flatten()
.filter_map(|i| match i {
Inline::Image(img) => Some(img),
_ => None,
})
.collect())
};
let pict = format!("{{\\pict\\wmetafile8\\picw38761\\pich12832\\picwgoal21972\\pichgoal7272\\picscalex45\\picscaley45 {hex}}}");
let shape = format!("{{\\shp{{\\*\\shpinst{{\\sp{{\\sn pib}}{{\\sv {pict}}}}}}}{{\\shprslt{pict}}}}}");
let got = images(format!("{{\\rtf1\\ansi Before{shape}After\\par}}"))?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert_eq!((img.ext.as_str(), img.data.as_ref()), ("wmf", &wmf));
// 21972 twips at 45% is 494.4 pt, shrunk to the 468 pt column like every imported picture.
assert_eq!(img.w, 468.0);
assert!((img.h - 7272.0 / 20.0 * 0.45 * 468.0 / 494.37).abs() < 0.1, "{}", img.h);
// No goal size: the natural size, 2540 × 1270 hundredths of a millimetre = 72 × 36 pt; under
// MM_TEXT (`\wmetafile1`) the same numbers are pixels.
let hex_of = |b: &[u8]| -> String { b.iter().map(|b| format!("{b:02x}")).collect() };
let mut emf = vec![0u8; 88];
emf[0] = 1;
emf[4] = 88;
emf[40..44].copy_from_slice(b" EMF");
let emf_hex = hex_of(&emf);
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\emfblip\\picw2540\\pich1270 {emf_hex}}}\\par}}"))?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert_eq!((img.ext.as_str(), img.w, img.h, img.data.as_ref()), ("emf", 72.0, 36.0, &emf));
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile1\\picw96\\pich48 {hex}}}\\par}}"))?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert_eq!((img.ext.as_str(), img.w, img.h), ("wmf", 72.0, 36.0));
// `\picscalex`/`\picscaley` scale the natural size when there is no goal size.
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw2540\\pich1270\\picscalex50\\picscaley200 {hex}}}\\par}}"))?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert_eq!((img.w, img.h), (36.0, 72.0));
// The other mapping modes use their own units: 0.1 mm, 0.01 in, 0.001 in and twips.
for (mode, w, h) in [(2, 254, 127), (4, 100, 50), (5, 1000, 500), (6, 1440, 720)] {
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile{mode}\\picw{w}\\pich{h} {hex}}}\\par}}"))?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert!((img.w - 72.0).abs() < 0.01 && (img.h - 36.0).abs() < 0.01, "mode {mode}: {} x {}", img.w, img.h);
}
// Hostile: a zero or negative natural size falls back to the default 72 pt square, and bytes
// that are no picture at all are dropped, whatever the control word says.
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw0\\pich-5 {hex}}}\\par}}"))?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert_eq!((img.w, img.h), (72.0, 72.0));
for blip in ["wmetafile8", "emfblip"] {
assert!(images(format!("{{\\rtf1\\ansi{{\\pict\\{blip}\\picw2540\\pich1270 0123456789abcdef}}\\par}}"))?.is_empty(), "{blip}");
}
// Saved as RTF and read back: the same picture, the EMF byte for byte, the WMF without the
// placeable header RTF has no room for.
let placeable: Vec<u8> = [0xD7u8, 0xCD, 0xC6, 0x9A].into_iter().chain([0u8; 18]).chain(wmf.iter().copied()).collect();
for (blip, data, want, ext) in [("emfblip", &emf, &emf, "emf"), ("wmetafile8", &wmf, &wmf, "wmf"), ("wmetafile8", &placeable, &wmf, "wmf")] {
let rtf = format!("{{\\rtf1\\ansi{{\\pict\\{blip}\\picw2540\\pich1270 {}}}\\par}}", hex_of(data));
let got = images(export(&import(rtf.as_bytes())?))?;
let [img] = got.as_slice() else { return Err(format!("{blip}: {} pictures", got.len())) };
assert_eq!((img.ext.as_str(), img.data.as_ref(), img.w, img.h), (ext, want, 72.0, 36.0), "{blip}");
}
// An EMF stored under `.wmf` (a DOCX can) is written as the EMF its bytes are.
let mut doc = import(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw2540\\pich1270 {hex}}}\\par}}").as_bytes())?;
let (key, bytes) = doc.media.iter_mut().next().ok_or("no media")?;
assert!(key.ends_with(".wmf"), "{key}");
*bytes = std::sync::Arc::new(emf.clone());
let rtf = export(&doc);
assert!(rtf.contains("\\emfblip") && !rtf.contains("\\wmetafile"), "{rtf}");
let got = images(rtf)?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert_eq!((img.ext.as_str(), img.data.as_ref()), ("emf", &emf));
// A placeable header with no records after it has nothing to write: no empty picture group.
*doc.media.values_mut().next().ok_or("no media")? = std::sync::Arc::new(placeable[..22].to_vec());
assert!(!export(&doc).contains("\\pict"), "an empty metafile is not written");
Ok(())
}
#[test]
fn unicode_in_info_bookmarks_and_field_instructions() {
// #435: `\uN` (with its `\ucN` fallback skipped) decodes in every text destination.
let rtf = br#"{\rtf1\ansi\uc1{\info{\title Caf\u233?}{\author Caf\u233\'3f}}{\*\bkmkstart caf\u233?}{\*\bkmkend caf\u233?}{\field{\*\fldinst{HYPERLINK "https://example.test/caf\u233?"}}{\fldrslt{link}}}\par Caf\u233?\par}"#;
let f = parse(rtf).unwrap();
assert_eq!(f.meta.title, "Café");
assert_eq!(f.meta.author, "Café");
let inl: Vec<&Inline> = f.blocks.iter().flat_map(|b| if let FBlock::Para(p) = b { p.inlines.iter().collect() } else { vec![] }).collect();
assert!(inl.iter().any(|i| matches!(i, Inline::Anchor(n) if n == "café")), "{inl:?}");
assert!(inl.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "link" && f.link.as_deref() == Some("https://example.test/café"))), "{inl:?}");
assert_eq!(texts(&f).last().map(String::as_str), Some("Normal:Café"));
}
/// A hand-written RTF with tracked changes: Alice inserts "added ", Bob deletes "gone", and
/// "both" is inserted by Alice then deleted by Bob.
pub(crate) const TRACKED: &[u8] = br"{\rtf1\ansi\deff0{\fonttbl{\f0 Arial;}}{\*\revtbl {Unknown;}{Alice;}{Bob;}}
\pard Keep {\revised\revauth1\revdttm-1480361314 added }text{\deleted\revauthdel2\revdttmdel-2016341499 gone} end{\revised\revauth1\revdttm-1480361314\deleted\revauthdel2\revdttmdel-2016341499 both}.\par}";
/// A revision's (author, date).
type Stamp = (String, String);
/// (text, insertion (author, date), deletion (author, date)) of each run of the first paragraph.
fn tracked_runs(doc: &Document) -> Vec<(String, Option<Stamp>, Option<Stamp>)> {
let rev = |i: Option<u32>, kind: craft_wordmodel::RevisionKind| {
i.map(|i| {
let r = doc.revisions.get(i as usize).unwrap();
assert_eq!(r.kind, kind);
(r.author.clone(), r.date.clone())
})
};
let Some(craft_wordmodel::Block::Para(p)) = doc.body.first().map(|b| &**b) else { panic!("no paragraph") };
p.run_ranges()
.map(|(r, cp)| {
(p.text[r].to_string(), rev(cp.ins, craft_wordmodel::RevisionKind::Insert), rev(cp.del, craft_wordmodel::RevisionKind::Delete))
})
.collect()
}
fn expected_tracked() -> Vec<(String, Option<(String, String)>, Option<(String, String)>)> {
let a = Some(("Alice".to_string(), "2024-03-15T10:30:00Z".to_string()));
let b = Some(("Bob".to_string(), "2025-01-02T08:05:00Z".to_string()));
vec![
("Keep ".into(), None, None),
("added ".into(), a.clone(), None),
("text".into(), None, None),
(" gone".into(), None, b.clone()),
(" end".into(), None, None),
(" both".into(), a, b),
(".".into(), None, None),
]
}
#[test]
fn tracked_changes_import_with_authors_and_dates() {
let doc = import(TRACKED).unwrap();
assert_eq!(tracked_runs(&doc), expected_tracked());
// One revision per (kind, author, date).
assert_eq!(doc.revisions.len(), 2);
}
#[test]
fn tracked_changes_survive_export_and_import() {
let doc = import(TRACKED).unwrap();
let rtf = export(&doc);
assert!(rtf.contains("{\\*\\revtbl {Unknown;}{Alice;}{Bob;}}"), "{rtf}");
assert!(rtf.contains("\\deleted\\revauthdel2\\revdttmdel-2016341499"), "{rtf}");
let back = import(rtf.as_bytes()).unwrap();
assert_eq!(tracked_runs(&back), expected_tracked());
}
#[test]
fn plain_text_export_still_drops_deleted_text() {
// Only RTF keeps the deletions: the shared flow for other formats leaves them out.
let doc = import(TRACKED).unwrap();
let flow = model::from_doc(&doc);
assert_eq!(texts(&flow), vec!["Normal:Keep added text end."]);
}
#[test]
fn dttm_converts_to_and_from_iso() {
assert_eq!(dttm_to_iso(-1480361314i32 as u32).as_deref(), Some("2024-03-15T10:30:00Z"));
assert_eq!(dttm_to_iso(-2016341499i32 as u32).as_deref(), Some("2025-01-02T08:05:00Z"));
assert_eq!(iso_to_dttm("2024-03-15T10:30:00Z"), Some(-1480361314i32 as u32));
assert_eq!(iso_to_dttm("2025-01-02T08:05:59.123+02:00"), Some(-2016341499i32 as u32));
assert_eq!(iso_to_dttm("2000-02-29T00:00:00Z").and_then(dttm_to_iso).as_deref(), Some("2000-02-29T00:00:00Z"));
// Zero, out-of-range fields and impossible days are no date.
assert_eq!(dttm_to_iso(0), None);
assert_eq!(dttm_to_iso(13 << 16 | 1 << 11 | 100 << 20), None);
assert_eq!(dttm_to_iso(2 << 16 | 30 << 11 | 101 << 20), None);
assert_eq!(dttm_to_iso(1 << 16 | 1 << 11 | 100 << 20 | 60), None);
assert_eq!(iso_to_dttm(""), None);
assert_eq!(iso_to_dttm("1899-12-31T00:00:00Z"), None);
assert_eq!(iso_to_dttm("2024-02-30T00:00:00Z"), None);
assert_eq!(iso_to_dttm("2024-1é-01T00:00:00Z"), None);
assert_eq!(iso_to_dttm("2024-03-15"), iso_to_dttm("2024-03-15T00:00:00Z"));
}
#[test]
fn hostile_revision_tables_and_indices() {
// An author index past the table, a negative one, and a malformed table.
let rtf = br"{\rtf1{\*\revtbl {Unknown;}{Ann}{;}{\b;}}{\revised\revauth99999999 a}{\deleted\revauthdel-5\revdttmdel4294967295 b}{\revised\revauth1 c}\par}";
let doc = import(rtf).unwrap();
let runs = tracked_runs(&doc);
assert_eq!(runs.first().and_then(|r| r.1.clone()), Some(("Unknown".to_string(), String::new())));
assert_eq!(runs.get(1).and_then(|r| r.2.clone()), Some(("Unknown".to_string(), String::new())));
assert_eq!(runs.get(2).and_then(|r| r.1.clone()), Some(("Ann".to_string(), String::new())));
// A huge table is capped, and indices past the cap are "Unknown".
let mut rtf = String::from("{\\rtf1{\\*\\revtbl ");
for i in 0..20_000 {
rtf.push_str(&format!("{{A{i};}}"));
}
rtf.push_str("}{\\revised\\revauth15000 x}{\\revised\\revauth9999 y}\\par}");
let doc = import(rtf.as_bytes()).unwrap();
let runs = tracked_runs(&doc);
assert_eq!(runs.first().and_then(|r| r.1.clone()).map(|a| a.0).as_deref(), Some("Unknown"));
assert_eq!(runs.get(1).and_then(|r| r.1.clone()).map(|a| a.0).as_deref(), Some("A9999"));
// Unterminated table and revision words outside the body never panic.
for bad in [&br"{\rtf1{\*\revtbl {Al"[..], br"{\rtf1{\*\revtbl \revised\deleted{x}}y\par}", br"{\rtf1\revauth\revdttm\deleted"] {
let _ = import(bad);
}
}
#[test]
fn unicode_author_names_survive_import_and_round_trip() {
// `\uN` in the author table: "José" and "Дмитрий" (not "Jos?" / "???????").
let rtf = br"{\rtf1\uc1{\*\revtbl {Unknown;}{Jos\u233?;}{\u1044?\u1084?\u1080?\u1090?\u1088?\u1080?\u1081?;}}{\revised\revauth1 a}{\deleted\revauthdel2 b}\par}";
let authors = |d: &Document| tracked_runs(d).into_iter().map(|r| (r.1.map(|a| a.0), r.2.map(|a| a.0))).collect::<Vec<_>>();
let want = vec![(Some("José".to_string()), None), (None, Some("Дмитрий".to_string()))];
let doc = import(rtf).unwrap();
assert_eq!(authors(&doc), want);
let back = import(export(&doc).as_bytes()).unwrap();
assert_eq!(authors(&back), want);
}
#[test]
fn pard_keeps_revision_marks() {
// Revision marks are character formatting: `\pard` resets paragraph formatting only.
let rtf = br"{\rtf1{\*\revtbl {Unknown;}{Bob;}}\deleted\revauthdel1 gone\par\pard more\par}";
let doc = import(rtf).unwrap();
let Some(craft_wordmodel::Block::Para(p)) = doc.body.get(1).map(|b| &**b) else { panic!("no second paragraph") };
assert_eq!(p.text, "more");
let del = p.props_of_char(0).del.and_then(|i| doc.revisions.get(i as usize));
assert_eq!(del.map(|r| (r.kind, r.author.as_str())), Some((craft_wordmodel::RevisionKind::Delete, "Bob")));
}
/// The body's paragraphs.
fn doc_paras(doc: &Document) -> Vec<&craft_wordmodel::Paragraph> {
doc.body
.iter()
.filter_map(|b| match &**b {
craft_wordmodel::Block::Para(p) => Some(p),
_ => None,
})
.collect()
}
/// A document of `paras` with `revisions`.
fn doc_with(paras: Vec<craft_wordmodel::Paragraph>, revisions: Vec<craft_wordmodel::Revision>) -> Document {
let mut doc = Document::new();
doc.body = paras.into_iter().map(craft_wordmodel::para_block).collect();
doc.revisions = revisions;
doc
}
/// Kind, author and date of revision `i`.
fn rev_of(doc: &Document, i: Option<u32>) -> Option<(craft_wordmodel::RevisionKind, String, String)> {
i.and_then(|i| doc.revisions.get(i as usize)).map(|r| (r.kind, r.author.clone(), r.date.clone()))
}
#[test]
fn character_formatting_change_round_trips() {
use craft_wordmodel::props::{CharProps, PropChange};
use craft_wordmodel::{Revision, RevisionKind};
// "Bold" was made bold by Alice; before, it was plain italic.
let old = CharProps { italic: Some(true), ..Default::default() };
let cp = CharProps { bold: Some(true), fmt_change: PropChange::boxed(0, old), ..Default::default() };
let doc =
doc_with(vec![craft_wordmodel::Paragraph::with_text("Bold", cp)], vec![Revision::new(RevisionKind::Format, "Alice", "2024-03-15T10:30:00Z")]);
let rtf = export(&doc);
assert!(rtf.contains("\\crauth1\\crdate-1480361314"), "{rtf}");
assert!(rtf.contains("{\\*\\oldcprops"), "{rtf}");
let back = import(rtf.as_bytes()).unwrap();
let paras = doc_paras(&back);
let p = paras.first().unwrap();
assert_eq!(p.text, "Bold");
let cp = p.props_of_char(0);
assert_eq!(cp.bold, Some(true));
let change = cp.fmt_change.as_deref().expect("formatting change read back");
assert_eq!(rev_of(&back, Some(change.rev)), Some((RevisionKind::Format, "Alice".into(), "2024-03-15T10:30:00Z".into())));
assert_eq!(change.old.italic, Some(true));
assert_ne!(change.old.bold, Some(true));
}
#[test]
fn paragraph_formatting_change_round_trips() {
use craft_wordmodel::props::{CharProps, ParaProps, PropChange};
use craft_wordmodel::{Align, Revision, RevisionKind};
// Bob centred a right-aligned paragraph.
let mut p = craft_wordmodel::Paragraph::with_text("Centred", CharProps::default());
p.props.align = Some(Align::Center);
p.props.fmt_change = PropChange::boxed(0, ParaProps { align: Some(Align::Right), ..Default::default() });
let doc = doc_with(vec![p], vec![Revision::new(RevisionKind::Format, "Bob", "2025-01-02T08:05:00Z")]);
let rtf = export(&doc);
assert!(rtf.contains("{\\*\\oldpprops"), "{rtf}");
assert!(rtf.contains("\\prauth1\\prdate-2016341499"), "{rtf}");
let back = import(rtf.as_bytes()).unwrap();
let paras = doc_paras(&back);
let p = paras.first().unwrap();
assert_eq!(p.text, "Centred");
assert_eq!(p.props.align, Some(Align::Center));
let change = p.props.fmt_change.as_deref().expect("paragraph formatting change read back");
assert_eq!(rev_of(&back, Some(change.rev)), Some((RevisionKind::Format, "Bob".into(), "2025-01-02T08:05:00Z".into())));
assert_eq!(change.old.align, Some(Align::Right));
}
#[test]
fn paragraph_mark_revisions_round_trip() {
use craft_wordmodel::props::CharProps;
use craft_wordmodel::{Revision, RevisionKind};
// Bob deleted the mark after "One" (joining it to "Two"); Alice inserted the one after "Two".
let mut one = craft_wordmodel::Paragraph::with_text("One", CharProps::default());
one.mark.del = Some(0);
let mut two = craft_wordmodel::Paragraph::with_text("Two", CharProps::default());
two.mark.ins = Some(1);
let three = craft_wordmodel::Paragraph::with_text("Three", CharProps::default());
let doc = doc_with(
vec![one, two, three],
vec![
Revision::new(RevisionKind::Delete, "Bob", "2025-01-02T08:05:00Z"),
Revision::new(RevisionKind::Insert, "Alice", "2024-03-15T10:30:00Z"),
],
);
let rtf = export(&doc);
let back = import(rtf.as_bytes()).unwrap();
let paras = doc_paras(&back);
let texts: Vec<&str> = paras.iter().map(|p| p.text.as_str()).collect();
assert_eq!(texts.get(..3), Some(&["One", "Two", "Three"][..]), "{rtf}");
let marks: Vec<_> = paras.iter().take(3).map(|p| (rev_of(&back, p.mark.ins), rev_of(&back, p.mark.del))).collect();
assert_eq!(
marks,
vec![
(None, Some((RevisionKind::Delete, "Bob".into(), "2025-01-02T08:05:00Z".into()))),
(Some((RevisionKind::Insert, "Alice".into(), "2024-03-15T10:30:00Z".into())), None),
(None, None),
],
"{rtf}"
);
}
#[test]
fn formatting_revisions_read_as_the_spec_writes_them() {
use craft_wordmodel::{Align, RevisionKind};
// RTF 1.9.1 "Revision Marks": `\crauth`/`\crdate` with `{\*\oldcprops …}` on a run,
// `\prauth`/`\prdate` with `{\*\oldpprops …}` on a paragraph, and a `\par` inside a
// `\revised` group.
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\revtbl {Unknown;}{Alice;}{Bob;}}
\pard\qc\prauth2\prdate-2016341499{\*\oldpprops \pard\ql}Plain {\b\crauth1\crdate-1480361314{\*\oldcprops \plain\i}bold}\par
\pard Next{\revised\revauth1\revdttm-1480361314 \par}After\par}";
let doc = import(rtf).unwrap();
let paras = doc_paras(&doc);
let texts: Vec<&str> = paras.iter().map(|p| p.text.as_str()).collect();
assert_eq!(texts.get(..3), Some(&["Plain bold", "Next", "After"][..]));
let p = paras.first().unwrap();
assert_eq!(p.props.align, Some(Align::Center));
let pc = p.props.fmt_change.as_deref().expect("paragraph change");
assert_eq!(rev_of(&doc, Some(pc.rev)), Some((RevisionKind::Format, "Bob".into(), "2025-01-02T08:05:00Z".into())));
assert_eq!(pc.old.align, Some(Align::Left));
assert!(p.props_of_char(0).fmt_change.is_none());
let cp = p.props_of_char(6);
assert_eq!(cp.bold, Some(true));
let cc = cp.fmt_change.as_deref().expect("character change");
assert_eq!(rev_of(&doc, Some(cc.rev)), Some((RevisionKind::Format, "Alice".into(), "2024-03-15T10:30:00Z".into())));
assert_eq!(cc.old.italic, Some(true));
assert_ne!(cc.old.bold, Some(true));
let next = paras.get(1).unwrap();
assert_eq!(rev_of(&doc, next.mark.ins), Some((RevisionKind::Insert, "Alice".into(), "2024-03-15T10:30:00Z".into())));
assert!(paras.first().unwrap().mark.ins.is_none());
assert!(paras.get(2).unwrap().mark.ins.is_none());
}
#[test]
fn list_item_with_a_formatting_change_keeps_its_list_on_reject() {
use model::{FmtChange, ListInfo};
// A numbered item Alice centred (it was left-aligned), and a second plain item.
let li = Some(ListInfo { ordered: true, level: 0, start: 1, ..Default::default() });
let mark = RevMark { author: "Alice".into(), date: "2024-03-15T10:30:00Z".into(), id: None };
let old = Para { list: li, align: Some(Align::Left), ..Default::default() };
let mut item = Para { list: li, align: Some(Align::Center), fmt_change: Some(Box::new(FmtChange { mark, old })), ..Default::default() };
item.push_text("Item", &Fmt::default());
let mut next = Para { list: li, ..Default::default() };
next.push_text("Next", &Fmt::default());
let flow = Flow { blocks: vec![FBlock::Para(item), FBlock::Para(next)], meta: Meta::default() };
let check = |doc: &Document, what: &str| {
let paras = doc_paras(doc);
let p = paras.first().unwrap();
assert!(p.props.numbering.is_some(), "{what}: the item is in a list");
let change = p.props.fmt_change.as_deref().unwrap_or_else(|| panic!("{what}: change kept"));
assert_eq!(change.old.align, Some(Align::Left), "{what}");
// Reject puts the old properties back: the item stays in its list, with its style.
let rejected = change.old.formatting();
assert_eq!(rejected.numbering, p.props.numbering, "{what}");
assert_eq!(rejected.style, p.props.style, "{what}");
};
let doc = model::to_doc(&flow);
check(&doc, "flow to document");
let rtf = export(&doc);
check(&import(rtf.as_bytes()).unwrap(), &format!("RTF round trip\n{rtf}"));
}
#[test]
fn paragraph_that_was_a_list_item_gets_its_old_list_back_on_reject() {
use model::{FmtChange, ListInfo};
// Alice took the paragraph out of a numbered list: it is no list item now, but was one.
let mark = RevMark { author: "Alice".into(), date: "2024-03-15T10:30:00Z".into(), id: None };
let old = Para { list: Some(ListInfo { ordered: true, level: 0, start: 1, ..Default::default() }), ..Default::default() };
let mut item = Para { fmt_change: Some(Box::new(FmtChange { mark, old })), ..Default::default() };
item.push_text("Item", &Fmt::default());
let flow = Flow { blocks: vec![FBlock::Para(item)], meta: Meta::default() };
let doc = model::to_doc(&flow);
let paras = doc_paras(&doc);
let p = paras.first().unwrap();
assert!(p.props.numbering.is_none());
let change = p.props.fmt_change.as_deref().expect("change kept");
assert!(change.old.formatting().numbering.is_some(), "Reject restores the list");
}
#[test]
fn numbered_item_made_bulleted_gets_its_numbered_list_back_on_reject() {
use model::{FmtChange, ListInfo};
let mark = RevMark { author: "Alice".into(), date: "2024-03-15T10:30:00Z".into(), id: None };
let old = Para { list: Some(ListInfo { ordered: true, level: 0, start: 1, ..Default::default() }), ..Default::default() };
let mut item = Para {
list: Some(ListInfo { ordered: false, level: 0, start: 1, ..Default::default() }),
fmt_change: Some(Box::new(FmtChange { mark, old })),
..Default::default()
};
item.push_text("Item", &Fmt::default());
let flow = Flow { blocks: vec![FBlock::Para(item)], meta: Meta::default() };
let doc = model::to_doc(&flow);
let paras = doc_paras(&doc);
let p = paras.first().unwrap();
let change = p.props.fmt_change.as_deref().expect("change kept");
let (now, was) = (p.props.numbering.unwrap(), change.old.formatting().numbering.unwrap());
assert_ne!(now.num, was.num, "the old list is a list of its own");
}
#[test]
fn heading_formatting_change_has_no_style_formatting_in_the_old_formatting() {
// A heading's runs repeat its style's bold and size; the old formatting does too.
let rtf = br"{\rtf1{\stylesheet{\s0 Normal;}{\s1 heading 1;}}{\*\revtbl {Unknown;}{Alice;}}
\pard\s1\b\fs32 {\i\crauth1{\*\oldcprops \plain\b\fs32}Head}\par}";
let doc = import(rtf).unwrap();
let paras = doc_paras(&doc);
let cp = paras.first().unwrap().props_of_char(0);
let old = cp.fmt_change.as_deref().map(|c| c.old.clone()).expect("change kept");
assert_eq!((old.bold, old.size), (cp.bold, cp.size));
}
#[test]
fn word_oldcprops_hold_only_the_changed_properties() {
use craft_wordmodel::RevisionKind;
// Word for Mac 16.113 saving tracked formatting changes as RTF: "beta" made bold,
// "gamma" (bold) grown from 12 to 16 pt. `\oldcprops` holds only what changed, with
// `\crauth`/`\crdate` inside the group; the rest of the old formatting is the run's.
let rtf = br"{\rtf1\ansi\deff0{\fonttbl{\f0 Times New Roman;}{\f1 Arial;}}{\*\revtbl {Unknown;}{Alice;}}
\pard\plain \f1\fs24 alpha {\f1\fs24\b\insrsid12079894\charrsid12079894 {\*\oldcprops \b0\crauth1\crdate-2014675576\insrsid12079894\charrsid12079894}beta} {\b\f1\fs32 {\*\oldcprops \fs24\crauth1\crdate-2014675576\insrsid12079894\charrsid12079894}gamma}\par}";
let doc = import(rtf).unwrap();
let paras = doc_paras(&doc);
let p = paras.first().unwrap();
assert_eq!(p.text, "alpha beta gamma");
let word_rev = Some((RevisionKind::Format, "Alice".to_string(), "2026-10-15T22:08:00Z".to_string()));
// beta: Reject takes the bold off; font and size stay.
let beta = p.props_of_char(6);
assert_eq!(beta.bold, Some(true));
let old = beta.fmt_change.as_deref().expect("beta's change");
assert_eq!(rev_of(&doc, Some(old.rev)), word_rev);
assert_ne!(old.old.bold, Some(true), "{:?}", old.old);
assert_eq!((old.old.font.as_deref(), old.old.size), (beta.font.as_deref(), Some(12.0)), "{:?}", old.old);
// gamma: Reject puts 12 pt back; bold and font stay.
let gamma = p.props_of_char(11);
assert_eq!(gamma.size, Some(16.0));
let old = gamma.fmt_change.as_deref().expect("gamma's change");
assert_eq!(rev_of(&doc, Some(old.rev)), word_rev);
assert_eq!((old.old.bold, old.old.font.as_deref(), old.old.size), (Some(true), gamma.font.as_deref(), Some(12.0)), "{:?}", old.old);
assert_eq!(gamma.font.as_deref(), Some("Arial"));
}
#[test]
fn word_oldpprops_hold_the_full_old_paragraph_formatting() {
use craft_wordmodel::{Align, RevisionKind};
// Word for Mac 16.113: a paragraph centred with tracking on. `\oldpprops` is the full
// old set from `\pard`; `\prauth`/`\prdate` are on the paragraph.
let rtf = br"{\rtf1\ansi\deff0{\fonttbl{\f0 Times New Roman;}}{\*\revtbl {Unknown;}{Alice;}}
\pard\plain \ltrpar\qc \li0\ri0\sa160\sl278\slmult1\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0\prauth1\prdate-2014675576{\*\oldpprops \pard \ltrpar\ql \li0\ri0\sa160\sl278\slmult1\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0\pararsid12079894}\fs24 Centred\par}";
let doc = import(rtf).unwrap();
let paras = doc_paras(&doc);
let p = paras.first().unwrap();
assert_eq!(p.text, "Centred");
assert_eq!(p.props.align, Some(Align::Center));
let c = p.props.fmt_change.as_deref().expect("paragraph change");
assert_eq!(rev_of(&doc, Some(c.rev)), Some((RevisionKind::Format, "Alice".into(), "2026-10-15T22:08:00Z".into())));
assert_eq!(c.old.align, Some(Align::Left));
assert_eq!(c.old.space_after, p.props.space_after);
assert_eq!(c.old.line_spacing, p.props.line_spacing);
assert!(p.props.space_after.is_some());
}
#[test]
fn oldcprops_are_written_as_the_differences() {
use craft_wordmodel::props::{CharProps, PropChange};
use craft_wordmodel::{Revision, RevisionKind};
let rev = || vec![Revision::new(RevisionKind::Format, "Alice", "2024-03-15T10:30:00Z")];
// Made bold: the group holds `\b0` and who changed it, nothing else.
let old = CharProps { font: Some("Arial".into()), size: Some(14.0), ..Default::default() };
let cp =
CharProps { bold: Some(true), font: Some("Arial".into()), size: Some(14.0), fmt_change: PropChange::boxed(0, old), ..Default::default() };
let rtf = export(&doc_with(vec![craft_wordmodel::Paragraph::with_text("Bold", cp)], rev()));
assert!(rtf.contains("{\\*\\oldcprops \\b0\\crauth1\\crdate-1480361314}"), "{rtf}");
// Several changes, on and off, round-trip.
let red = craft_wordmodel::props::TextColor::Rgb(craft_wordmodel::props::Rgb(255, 0, 0));
let old = CharProps {
italic: Some(true),
underline: Some(craft_wordmodel::props::Underline::Single),
size: Some(14.0),
color: Some(red),
font: Some("Arial".into()),
..Default::default()
};
let cp = CharProps {
bold: Some(true),
size: Some(16.0),
font: Some("Courier New".into()),
fmt_change: PropChange::boxed(0, old.clone()),
..Default::default()
};
let rtf = export(&doc_with(vec![craft_wordmodel::Paragraph::with_text("Mixed", cp)], rev()));
let back = import(rtf.as_bytes()).unwrap();
let paras = doc_paras(&back);
let cp = paras.first().unwrap().props_of_char(0);
assert_eq!((cp.bold, cp.size), (Some(true), Some(16.0)), "{rtf}");
let got = &cp.fmt_change.as_deref().expect("change read back").old;
assert_ne!(got.bold, Some(true), "{rtf}");
assert_eq!(
(got.italic, got.underline, got.size, got.color, got.font.as_deref()),
(old.italic, old.underline, old.size, old.color, old.font.as_deref()),
"{rtf}"
);
}
#[test]
fn old_formatting_in_styled_paragraphs_is_the_styles() {
use model::{FmtChange, Kind};
let mark = || RevMark { author: "Alice".into(), date: "2024-03-15T10:30:00Z".into(), id: None };
// A heading 2 run grown to 20 pt and a code run grown to 14 pt, both from no direct
// formatting: Reject gives back the style's size, not RTF's default 12 pt. (A code run's
// own font doesn't reach the flow from a document, so the code font is checked below.)
let mut head = Para { kind: Kind::Heading(2), ..Default::default() };
head.push_text(
"Head",
&Fmt { size: Some(20.0), fmt_change: Some(Box::new(FmtChange { mark: mark(), old: Fmt::default() })), ..Default::default() },
);
let mut code = Para { kind: Kind::Code, ..Default::default() };
code.push_text(
"let x",
&Fmt { size: Some(14.0), fmt_change: Some(Box::new(FmtChange { mark: mark(), old: Fmt::default() })), ..Default::default() },
);
let flow = Flow { blocks: vec![FBlock::Para(head), FBlock::Para(code)], meta: Meta::default() };
let rtf = export(&model::to_doc(&flow));
let back = parse(rtf.as_bytes()).unwrap();
let runs: Vec<&Fmt> = back
.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");
// The import takes a heading's direct size as the style's (as on main); the old size
// written is the style's, so Reject gives it whichever way the import reads it.
assert!(rtf.contains("{\\*\\oldcprops \\fs32\\crauth1"), "{rtf}");
let old = &head.fmt_change.as_deref().expect("heading change").old;
assert!(old.size.is_none_or(|s| s == 16.0), "{old:?}\n{rtf}");
let code = runs.get(1).expect("code run");
assert!(rtf.contains("{\\*\\oldcprops \\fs20\\crauth1"), "{rtf}");
let old = &code.fmt_change.as_deref().expect("code change").old;
assert!(old.size.is_none_or(|s| s == 10.0), "{old:?}\n{rtf}");
assert!(old.font.is_none() || old.code, "{old:?}\n{rtf}");
}
#[test]
fn old_formatting_in_a_code_paragraph_has_the_code_font() {
use model::{FmtChange, Kind};
// A code run set in Arial from no direct font: the old font written is the code font.
let mark = RevMark { author: "Alice".into(), date: String::new(), id: None };
let mut p = Para { kind: Kind::Code, ..Default::default() };
p.push_text("x", &Fmt { font: Some("Arial".into()), fmt_change: Some(Box::new(FmtChange { mark, old: Fmt::default() })), ..Default::default() });
let mut w = Writer {
marks: Vec::new(),
t: Tables { fonts: vec!["Aptos".into()], colors: Vec::new() },
lists: Vec::new(),
authors: None,
base: Fmt::default(),
sections: Vec::new(),
sect_i: 0,
};
let mut out = String::new();
w.para(&p, false, None, "\\par", &mut out);
let mono = w.t.font(model::MONO_FONT);
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() {
// 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));
// 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}");
}
/// Escaped semicolons in a font name stop at the limit, as other characters do (review of #3862).
#[test]
fn escaped_semicolons_in_a_font_name_stop_at_the_limit() {
let mut r = reader::Reader::default();
r.st.dest = state::Dest::FontTbl;
for _ in 0..100_000 {
r.ch(';');
}
assert_eq!(r.font_name.len(), model::MAX_FONT_NAME + 1);
let rtf = format!(r"{{\rtf1{{\fonttbl{{\f0 {};}}}}\f0 x\par}}", r"\'3b".repeat(model::MAX_FONT_NAME + 1));
let runs = text_runs(rtf.as_bytes());
assert_eq!(run_fmt(&runs, "x").font, None);
}
#[test]
fn a_semicolon_in_a_font_name_round_trips() {
// `;` ends a `\fonttbl` entry, so it is written `\'3b` and read back as part of the name (#3740).
let fonts = [("one", "Semi;Colon"), ("two", "Semi;Other"), ("three", "Tail;"), ("four", "Line\nBreak")];
let mut p = Para::default();
for (t, font) in fonts {
p.push_text(t, &Fmt { font: Some(font.into()), ..Default::default() });
}
let flow = Flow { blocks: vec![FBlock::Para(p)], meta: Meta::default() };
let rtf = export(&model::to_doc(&flow));
assert!(rtf.contains(r"Semi\'3bColon;") && !rtf.contains(r"\line"), "{rtf}");
let runs = text_runs(rtf.as_bytes());
for (t, font) in fonts {
assert_eq!(run_fmt(&runs, t).font.as_deref(), Some(font.replace('\n', "").as_str()), "{rtf}");
}
// A Unicode escape is part of the name too, in a multibyte code page as well; a literal `;` ends it.
let runs = text_runs(br"{\rtf1\ansi\ansicpg932{\fonttbl{\f0 A\u59?B;}{\f1 C\'3bD;}}\f0 x {\f1 y}\par}");
assert_eq!((run_fmt(&runs, "x ").font.as_deref(), run_fmt(&runs, "y").font.as_deref()), (Some("A;B"), Some("C;D")));
}
#[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]
fn pard_inside_oldcprops_keeps_the_words_before_it() {
let doc = import(br"{\rtf1{\*\revtbl {Unknown;}{Alice;}}{\b{\*\oldcprops \i\crauth1\pard\b0}x}\par}").unwrap();
let paras = doc_paras(&doc);
let cp = paras.first().unwrap().props_of_char(0);
let c = cp.fmt_change.as_deref().expect("change kept");
assert_eq!(c.old.italic, Some(true));
assert_ne!(c.old.bold, Some(true));
assert_eq!(rev_of(&doc, Some(c.rev)).map(|r| r.1).as_deref(), Some("Alice"));
}
#[test]
fn a_section_break_under_a_deletion_has_a_deleted_mark() {
let rtf = br"{\rtf1{\*\revtbl {Unknown;}{Bob;}}\deleted\revauthdel1 one\sect\pard\plain two\par}";
let doc = import(rtf).unwrap();
let paras = doc_paras(&doc);
assert_eq!(paras.first().map(|p| p.text.as_str()), Some("one"));
assert_eq!(rev_of(&doc, paras.first().and_then(|p| p.mark.del)).map(|r| r.1).as_deref(), Some("Bob"));
}
#[test]
fn hostile_formatting_revisions_never_panic() {
let text = |rtf: &[u8]| import(rtf).map(|d| doc_paras(&d).iter().map(|p| p.text.clone()).collect::<Vec<_>>());
// Old properties without an author: the text is kept and nothing else appears.
assert_eq!(text(br"{\rtf1{\*\oldcprops \b}x\par}").unwrap().first().map(String::as_str), Some("x"));
// Huge and negative indices and dates.
let doc =
import(br"{\rtf1{\b\crauth2147483647\crdate4294967295{\*\oldcprops \i}x}\pard\prauth-5\prdate4294967295{\*\oldpprops \qc}y\par}").unwrap();
let paras = doc_paras(&doc);
let author = |i: Option<u32>| rev_of(&doc, i).map(|r| r.1);
assert_eq!(paras.first().map(|p| p.text.as_str()), Some("xy"));
assert_eq!(author(paras.first().and_then(|p| p.props_of_char(0).fmt_change.as_deref().map(|c| c.rev))).as_deref(), Some("Unknown"));
// Structure inside old properties is ignored: no paragraphs, cells or text come from it.
assert_eq!(text(br"{\rtf1\pard{\*\oldpprops \pard\qc\par\cell\row\trowd\cellx100 junk}z\par}").unwrap().first().map(String::as_str), Some("z"));
assert_eq!(text(br"{\rtf1{\b\crauth1{\*\oldcprops {\*\oldcprops \i}\par x}y}\par}").unwrap().first().map(String::as_str), Some("y"));
// Border words in old properties don't edit the cell border being defined.
let f = parse(br"{\rtf1\ansi\trowd\clbrdrt\brdrs\brdrw10{\*\oldpprops \brdrdb\brdrw120\brdrcf1}\cellx2000\pard\intbl a\cell\row}").unwrap();
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
let top = t.rows[0][0].borders.and_then(|b| b.top).unwrap();
assert_eq!((top.style, top.width, top.color), (craft_wordmodel::props::BorderStyle::Single, 0.5, None));
// Unterminated groups and deep nesting.
for bad in [
&br"{\rtf1\pard\prauth1\prdate5{\*\oldpprops \pard\qc"[..],
br"{\rtf1{\crauth1{\*\oldcprops \b",
br"{\rtf1\crauth\crdate\prauth\prdate{\*\oldcprops}{\*\oldpprops}",
] {
let _ = import(bad);
}
let mut deep = String::from("{\\rtf1\\crauth1");
for _ in 0..20_000 {
deep.push_str("{\\*\\oldcprops \\b");
}
deep.push_str(" x\\par}");
let _ = import(deep.as_bytes());
}
#[test]
fn sections_keep_their_page_columns_numbering_and_headers() {
// A4 portrait → landscape restarting at 1 with its own header → continuous two columns
// → portrait. A `\sectd` inside a footer group is the footer's, not the body's.
let rtf = br"{\rtf1\ansi\paperw11906\paperh16838\margl1134\margr1134
\sectd\headery708\footery708
{\header \pard One head\par}
{\footer \pard Page {\field{\*\fldinst PAGE}{\fldrslt 1}}\sectd\par}
\pard First\par\sect
\sectd\lndscpsxn\pgwsxn16838\pghsxn11906\headery708\pgnrestart\pgnstarts1\pgnlcrm\titlepg
{\header \pard Two head\par}
{\headerf \pard Two first\par}
{\footer \pard Two foot\sectd\lndscpsxn\par}
\pard Second\par
\trowd\cellx2000\pard\intbl cell\cell\row
\pard{\sect}
\sectd\lndscpsxn\pgwsxn16838\pghsxn11906\headery708\sbknone\pgnrestart\cols2\colsx567\linebetcol
\pard Third\sect
\sectd\headery708\sbkodd
\pard Fourth\par}";
let d = import(rtf).unwrap();
let secs = d.sections();
assert_eq!(secs.len(), 4, "{secs:?}");
let s: Vec<&craft_wordmodel::SectionProps> = secs.iter().map(|(_, s)| *s).collect();
assert_section_geometry(&s);
assert_section_breaks_and_layout(&s);
assert_section_headers_and_footers(&d, &s);
assert_section_body_boundaries(&d, &secs);
assert_section_page_defaults();
}
fn assert_section_geometry(s: &[&craft_wordmodel::SectionProps]) {
// Page size and orientation; the document's margins apply to every section.
for (i, land) in [(0, false), (1, true), (2, true), (3, false)] {
assert_eq!(s[i].landscape, land, "section {i}");
let (w, h) = if land { (841.9, 595.3) } else { (595.3, 841.9) };
assert!((s[i].page_w - w).abs() < 0.1 && (s[i].page_h - h).abs() < 0.1, "section {i}: {} x {}", s[i].page_w, s[i].page_h);
assert!((s[i].margin_left - 56.7).abs() < 0.1, "section {i}");
assert!((s[i].header - 35.4).abs() < 0.1, "section {i}");
}
}
fn assert_section_breaks_and_layout(s: &[&craft_wordmodel::SectionProps]) {
use craft_wordmodel::section::{NumFormat, SectionStart};
assert_eq!(
s.iter().map(|s| s.start).collect::<Vec<_>>(),
[SectionStart::NextPage, SectionStart::NextPage, SectionStart::Continuous, SectionStart::OddPage]
);
// Columns.
assert_eq!(s[2].columns.count, 2);
assert!((s[2].columns.space - 28.35).abs() < 0.01 && s[2].columns.separator);
assert_eq!(s[0].columns.count, 1);
assert_eq!(s[3].columns.count, 1);
// Page numbering: restart at 1 (lower roman) in 2 and 3; section 4 carries on.
assert_eq!(s.iter().map(|s| s.page_num_start).collect::<Vec<_>>(), [None, Some(1), Some(1), None]);
assert_eq!(s[1].page_num_format, NumFormat::LowerRoman);
assert_eq!(s[0].page_num_format, NumFormat::Decimal);
assert!(!s[0].title_page && s[1].title_page && !s[3].title_page);
}
fn assert_section_headers_and_footers(d: &craft_wordmodel::Document, s: &[&craft_wordmodel::SectionProps]) {
let text = |id: Option<u32>| {
let p = id.and_then(|id| d.parts.get(&id)).unwrap();
p.blocks
.iter()
.filter_map(|b| if let craft_wordmodel::Block::Para(p) = &**b { Some(p.text.clone()) } else { None })
.collect::<Vec<_>>()
.join("|")
};
assert_eq!(text(s[0].headers.default), "One head");
assert!(text(s[0].footers.default).starts_with("Page"));
assert_eq!(text(s[1].headers.default), "Two head");
assert_eq!(text(s[1].headers.first), "Two first");
assert_eq!(text(s[1].footers.default), "Two foot");
assert!(s[0].headers.first.is_none());
for i in [2, 3] {
assert_eq!((s[i].headers, s[i].footers), Default::default(), "section {i}");
}
}
fn assert_section_body_boundaries(d: &craft_wordmodel::Document, secs: &[(usize, &craft_wordmodel::SectionProps)]) {
// Each section ends on its own paragraph, after the text it holds.
let body_text = |i: usize| match d.body.get(i).map(|b| &**b) {
Some(craft_wordmodel::Block::Para(p)) => p.text.clone(),
_ => "<table>".into(),
};
assert_eq!(body_text(secs[0].0 - 1), "First");
assert_eq!(body_text(secs[1].0 - 1), "<table>");
assert_eq!(body_text(secs[2].0), "Third");
assert_eq!(body_text(secs[3].0), "Fourth");
}
fn assert_section_page_defaults() {
// A landscape document default doesn't turn a section that gives its own portrait page.
let d = import(br"{\rtf1\ansi\paperw16838\paperh11906\landscape\sectd a\sect\sectd\pgwsxn11906\pghsxn16838 b\par}").unwrap();
let secs = d.sections();
assert_eq!(secs.len(), 2);
assert!(secs[0].1.landscape && secs[0].1.page_w > secs[0].1.page_h, "{:?}", secs[0].1);
assert!(!secs[1].1.landscape && secs[1].1.page_w < secs[1].1.page_h, "{:?}", secs[1].1);
}
#[test]
fn export_writes_every_section() {
use craft_wordmodel::section::{NumFormat, SectionStart};
use craft_wordmodel::{Block, SectionProps};
let mut d = craft_wordmodel::Document::from_text("one\ntwo\nthree");
let s1 = SectionProps { margin_left: 90.0, header: 30.0, footer: 40.0, ..Default::default() };
let mut s2 = SectionProps { start: SectionStart::Continuous, page_w: 792.0, page_h: 612.0, landscape: true, ..Default::default() };
s2.columns.count = 2;
s2.columns.space = 18.0;
s2.columns.separator = true;
s2.page_num_start = Some(3);
s2.page_num_format = NumFormat::LowerRoman;
let mut s3 = SectionProps { start: SectionStart::OddPage, title_page: true, ..Default::default() };
s3.page_num_format = NumFormat::UpperLetter;
for (i, s) in [(0usize, s1.clone()), (1, s2.clone())] {
if let Some(Block::Para(p)) = d.body.get_mut(i).map(std::sync::Arc::make_mut) {
p.section = Some(Box::new(s));
}
}
d.last_section = s3.clone();
let rtf = export(&d);
assert_eq!(rtf.matches("\\sect\n").count() + rtf.matches("\\sect\\").count(), 2, "{rtf}");
let back = import(rtf.as_bytes()).unwrap();
let secs = back.sections();
assert_eq!(secs.len(), 3, "{rtf}");
let got: Vec<&SectionProps> = secs.iter().map(|(_, s)| *s).collect();
for (g, want) in got.iter().zip([&s1, &s2, &s3]) {
assert_eq!(g.start, want.start);
assert_eq!(g.landscape, want.landscape);
assert!((g.page_w - want.page_w).abs() < 0.1 && (g.page_h - want.page_h).abs() < 0.1);
for (a, b) in [
(g.margin_left, want.margin_left),
(g.margin_right, want.margin_right),
(g.margin_top, want.margin_top),
(g.margin_bottom, want.margin_bottom),
] {
assert!((a - b).abs() < 0.1);
}
assert!((g.header - want.header).abs() < 0.1 && (g.footer - want.footer).abs() < 0.1);
assert_eq!(g.columns.count, want.columns.count);
assert_eq!(g.columns.separator, want.columns.separator);
assert_eq!(g.page_num_start, want.page_num_start);
assert_eq!(g.page_num_format, want.page_num_format);
assert_eq!(g.title_page, want.title_page);
}
assert!((got[1].columns.space - 18.0).abs() < 0.1);
// Each section keeps its text.
assert_eq!(secs.iter().map(|(i, _)| *i).collect::<Vec<_>>(), [0, 1, 2]);
// One section: no section break.
let one = export(&craft_wordmodel::Document::from_text("a\nb"));
assert!(!one.contains("\\sect\n") && !one.contains("\\sect\\"), "{one}");
}
#[test]
fn export_keeps_a_single_sections_formatting() {
use craft_wordmodel::SectionProps;
let mut d = craft_wordmodel::Document::from_text("a\nb");
d.last_section = SectionProps { title_page: true, ..Default::default() };
d.last_section.columns.count = 2;
d.last_section.columns.space = 20.0;
let back = import(export(&d).as_bytes()).unwrap();
assert_eq!(back.sections().len(), 1);
assert_eq!(back.last_section.columns.count, 2);
assert!(back.last_section.title_page);
}
#[test]
fn export_keeps_a_section_that_ends_the_body() {
use craft_wordmodel::{Block, SectionProps};
let mut d = craft_wordmodel::Document::from_text("a\nb");
let s1 = SectionProps { landscape: true, page_w: 792.0, page_h: 612.0, ..Default::default() };
if let Some(Block::Para(p)) = d.body.get_mut(1).map(std::sync::Arc::make_mut) {
p.section = Some(Box::new(s1));
}
d.last_section.columns.count = 3;
let back = import(export(&d).as_bytes()).unwrap();
let secs = back.sections();
assert_eq!(secs.len(), 2);
assert!(secs[0].1.landscape);
assert_eq!(secs[1].1.columns.count, 3);
}
#[test]
fn export_writes_no_section_break_inside_a_table() {
use craft_wordmodel::{Block, SectionProps};
let mut d = craft_wordmodel::Document::from_text("a");
let mut t = craft_wordmodel::Table::new(1, 1, 400.0);
if let Some(Block::Para(p)) =
t.rows.first_mut().and_then(|r| r.cells.first_mut()).and_then(|c| c.blocks.first_mut()).map(std::sync::Arc::make_mut)
{
p.section = Some(Box::new(SectionProps::default()));
}
d.body.push(std::sync::Arc::new(Block::Table(t)));
let rtf = export(&d);
assert!(!rtf.contains("\\sect\n") && !rtf.contains("\\sect\\") && rtf.matches("\\sectd").count() == 1, "{rtf}");
assert_eq!(import(rtf.as_bytes()).unwrap().sections().len(), 1);
}
#[test]
fn sections_are_capped_and_a_trailing_sect_adds_no_empty_section() {
// A file that ends with `\sect` has no empty section after it.
let d = import(br"{\rtf1\ansi\sectd\sbknone a\sect\sectd\lndscpsxn b\sect}").unwrap();
let secs = d.sections();
assert_eq!(secs.len(), 2, "{secs:?}");
assert!(secs[1].1.landscape && !secs[0].1.landscape);
// Hostile: a flood of section breaks with headers is capped and never panics.
let mut rtf = b"{\\rtf1\\ansi".to_vec();
for _ in 0..(MAX_SECTIONS + 50) {
rtf.extend_from_slice(b"\\sectd\\cols99999\\colsx-5\\pgnrestart\\pgnstarts-7{\\header h\\par}{\\footerl f\\par}x\\sect");
}
rtf.push(b'}');
let d = import(&rtf).unwrap();
let secs = d.sections();
assert_eq!(secs.len(), MAX_SECTIONS);
// Each section keeps its own header and footer; the ones past the cap replace the last's.
assert!(d.parts.len() <= 2 * MAX_SECTIONS, "{}", d.parts.len());
assert!(secs.iter().all(|(_, s)| s.columns.count <= 45 && s.columns.space >= 0.0 && s.page_num_start == Some(0)));
}
#[test]
fn rejects_non_rtf() {
assert!(parse(b"hello").is_err());
assert!(parse(b"{\\rtf1").is_ok());
}
#[test]
fn escapes() {
assert_eq!(esc("a{b}\\c\té😀"), "a\\{b\\}\\\\c\\tab \\u233?\\u-10179?\\u-8704?");
assert_eq!(esc("col1\u{000E}col2"), "col1\\column col2");
}
#[test]
fn column_break_import() {
let f = parse(br"{\rtf1\ansi \pard a\column b\par}").unwrap();
assert_eq!(texts(&f), ["Normal:a\u{000E}b"]);
}
#[test]
fn hyperlink_field_targets() {
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com" \l "heading""#), Some("http://example.com#heading".into()));
assert_eq!(hyperlink(r#"HYPERLINK \l "heading""#), Some("#heading".into()));
assert_eq!(hyperlink(r#"Hyperlink "http://example.com""#), Some("http://example.com".into()));
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/path\latest""#), Some("http://example.com/pathlatest".into()));
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into()));
// A tooltip or frame before the address, a longer field name, escapes and the writer's `%22`.
assert_eq!(hyperlink(r#"HYPERLINK \o "tip" \t "_blank" "http://example.com""#), Some("http://example.com".into()));
assert_eq!(hyperlink(r#"HYPERLINKX "http://example.com""#), None);
assert_eq!(hyperlink(r#"HYPERLINK "C:\\Docs\\f.rtf""#), Some(r"C:\Docs\f.rtf".into()));
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/a%22b""#), Some(r#"http://example.com/a"b"#.into()));
}
/// A link with a quote in it survives export and import: the writer stores it as `%22`.
#[test]
fn a_quote_in_a_link_round_trips() {
let rtf = br#"{\rtf1\ansi{\field{\*\fldinst{HYPERLINK "http://a.b/x%22y"}}{\fldrslt{link}}}\par}"#;
let link = |d: &Document| d.body.iter().find_map(|b| b.as_para()?.runs.iter().find_map(|r| r.props.link.clone()));
let d = import(rtf).unwrap();
assert_eq!(link(&d).as_deref(), Some(r#"http://a.b/x"y"#));
let exported = export(&d);
assert!(exported.contains("x%22y"), "{exported}");
assert_eq!(link(&import(exported.as_bytes()).unwrap()).as_deref(), Some(r#"http://a.b/x"y"#));
}
/// Backslashes in a link survive export and import, a trailing one and two in a row too.
#[test]
fn backslashes_in_a_link_round_trip() {
let link = |d: &Document| d.body.iter().find_map(|b| b.as_para()?.runs.iter().find_map(|r| r.props.link.clone()));
for target in [r"docs\", r"a\\b", r"..\up\f.doc"] {
let mut d = import(br#"{\rtf1\ansi{\field{\*\fldinst{HYPERLINK "x"}}{\fldrslt{link}}}\par}"#).unwrap();
for b in d.body.iter_mut() {
if let craft_wordmodel::Block::Para(p) = std::sync::Arc::make_mut(b) {
p.runs.iter_mut().filter(|r| r.props.link.is_some()).for_each(|r| r.props.link = Some(target.into()));
}
}
assert_eq!(link(&d).as_deref(), Some(target));
let exported = export(&d);
assert_eq!(link(&import(exported.as_bytes()).unwrap()).as_deref(), Some(target), "{exported}");
}
}
#[test]
fn distributed_alignment_round_trips() {
let rtf = br"{\rtf1\ansi\pard\qd Distributed\par\pard\qj Justified\par}";
let flow = parse(rtf).unwrap();
let p0 = match &flow.blocks[0] {
FBlock::Para(p) => p.align,
_ => panic!("expected para"),
};
let p1 = match &flow.blocks[1] {
FBlock::Para(p) => p.align,
_ => panic!("expected para"),
};
assert_eq!(p0, Some(Align::Distribute));
assert_eq!(p1, Some(Align::Justify));
let doc = import(rtf).unwrap();
let exported = export(&doc);
assert!(exported.contains("\\qd"), "expected \\qd in: {exported}");
assert!(exported.contains("\\qj"), "expected \\qj in: {exported}");
}
#[test]
fn table_cell_vertical_alignment() {
let rtf = br"{\rtf1\ansi\trowd\clvertalt\cellx1000\clvertalc\cellx2000\clvertalb\cellx3000\intbl top\cell center\cell bottom\cell\row}";
let doc = import(rtf).unwrap();
let craft_wordmodel::Block::Table(t) = &*doc.body[0] else { panic!() };
assert_eq!(t.rows[0].cells[0].props.valign, Some(VAlign::Top));
assert_eq!(t.rows[0].cells[1].props.valign, Some(VAlign::Center));
assert_eq!(t.rows[0].cells[2].props.valign, Some(VAlign::Bottom));
let exported = export(&doc);
assert!(exported.contains("\\clvertalt"));
assert!(exported.contains("\\clvertalc"));
assert!(exported.contains("\\clvertalb"));
}
#[test]
fn rtf_backslash_space_and_special_breaks() {
let rtf = br"{\rtf1\ansi A\ B\~C\zwbo D\softline E\softpage F\par}";
let doc = import(rtf).unwrap();
let p = doc.body.first().unwrap().as_para().unwrap();
assert_eq!(p.text, "A\u{00A0}B\u{00A0}C\u{200B}D\nE\u{000C}F");
}
#[test]
fn compound_borders_round_trip() {
use craft_wordmodel::props::BorderStyle;
let lines = [
("brdrtnthsg", BorderStyle::ThinThickSmallGap),
("brdrthtnsg", BorderStyle::ThickThinSmallGap),
("brdrtnthtnsg", BorderStyle::ThinThickThinSmallGap),
("brdrtnthmg", BorderStyle::ThinThickMediumGap),
("brdrthtnmg", BorderStyle::ThickThinMediumGap),
("brdrtnthtnmg", BorderStyle::ThinThickThinMediumGap),
("brdrtnthlg", BorderStyle::ThinThickLargeGap),
("brdrthtnlg", BorderStyle::ThickThinLargeGap),
("brdrtnthtnlg", BorderStyle::ThinThickThinLargeGap),
];
let body: String = lines.iter().map(|(w, _)| format!("\\pard\\brdrt\\{w}\\brdrw30 {w}\\par\n")).collect();
let rtf = format!("{{\\rtf1\\ansi\n{body}}}");
let tops = |d: &craft_wordmodel::Document| -> Vec<Option<BorderStyle>> {
d.body
.iter()
.filter_map(|b| b.as_para())
.filter(|p| !p.plain_text().is_empty())
.map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style))
.collect()
};
let want: Vec<Option<BorderStyle>> = lines.iter().map(|(_, s)| Some(*s)).collect();
let d = import(rtf.as_bytes()).unwrap();
assert_eq!(tops(&d), want);
let back = import(export(&d).as_bytes()).unwrap();
assert_eq!(tops(&back), want);
}