wordcraft/crates/docx/tests/roundtrip.rs

1106 lines
51 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.

//! Round-trip tests: build documents programmatically → write → read → compare.
use std::sync::Arc;
use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
use wordcraft_doc::table::{Cell, Table};
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Watermark, para_block};
fn rt(doc: &Document) -> Document {
let bytes = wordcraft_docx::write(doc).expect("write");
wordcraft_docx::read(&bytes).expect("read")
}
fn paras(doc: &Document) -> Vec<&Paragraph> {
doc.body.iter().filter_map(|b| b.as_para()).collect()
}
fn part_text(doc: &Document, id: Option<u32>) -> String {
id.and_then(|i| doc.parts.get(&i))
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<Vec<_>>().join("\n"))
.unwrap_or_default()
}
fn doc_with(blocks: Vec<Paragraph>) -> Document {
let mut d = Document::new();
d.body = blocks.into_iter().map(para_block).collect();
d
}
/// A paragraph built from (text, props) runs.
fn para_runs(runs: &[(&str, CharProps)]) -> Paragraph {
let mut p = Paragraph::new();
for (t, c) in runs {
let off = p.len();
p.insert_text(off, t, c).unwrap();
}
p
}
fn tiny_png() -> Vec<u8> {
let img = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 80, y as u8 * 120, 200, 255]));
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
#[test]
fn every_char_prop_round_trips() {
let all = CharProps {
style: Some("Strong".into()),
font: Some("Georgia".into()),
size: Some(10.5),
bold: Some(true),
italic: Some(false),
underline: Some(Underline::DotDash),
underline_color: Some(Rgb(1, 2, 3)),
strike: Some(true),
double_strike: Some(false),
color: Some(TextColor::Rgb(Rgb(0xAA, 0x10, 0x20))),
highlight: Some(Highlight::Turquoise),
shading: Some(Rgb(0xEE, 0xEE, 0x00)),
border: Some(Border { style: BorderStyle::Double, width: 1.5, color: Some(Rgb(0xC0, 0, 0)), space: 2.0 }),
vert_align: Some(VertAlign::Superscript),
caps: Some(true),
small_caps: Some(false),
hidden: Some(true),
spacing: Some(1.5),
scale: Some(150.0),
position: Some(3.0),
kern: Some(8.0),
outline: Some(true),
shadow: Some(true),
emboss: Some(false),
engrave: Some(true),
lang: Some("fr-FR".into()),
no_proof: Some(true),
rtl: Some(false),
cs: Some(true),
font_cs: Some("B Nazanin".into()),
size_cs: Some(13.0),
bold_cs: Some(false),
italic_cs: Some(true),
lang_bidi: Some("fa-IR".into()),
link: None,
ins: None,
del: None,
};
let auto =
CharProps { color: Some(TextColor::Auto), highlight: Some(Highlight::None), vert_align: Some(VertAlign::Subscript), ..Default::default() };
let d = doc_with(vec![para_runs(&[("plain ", CharProps::default()), ("everything", all.clone()), (" auto", auto.clone())])]);
let r = rt(&d);
let p = paras(&r)[0];
assert_eq!(p.text, "plain everything auto");
assert_eq!(p.runs.len(), 3);
assert_eq!(p.runs[1].props, all);
assert_eq!(p.runs[2].props, auto);
assert_eq!(p.runs[0].props, CharProps::default());
}
#[test]
fn every_para_prop_round_trips() {
let b = Border { style: BorderStyle::Double, width: 0.75, color: Some(Rgb(9, 8, 7)), space: 4.0 };
let pp = ParaProps {
style: Some("Heading1".into()),
align: Some(Align::Justify),
indent_left: Some(36.0),
indent_right: Some(18.0),
indent_first: Some(-18.0),
space_before: Some(6.0),
space_after: Some(12.0),
line_spacing: Some(LineSpacing::Multiple(1.5)),
contextual_spacing: Some(true),
keep_next: Some(true),
keep_lines: Some(false),
page_break_before: Some(true),
widow_control: Some(false),
outline_level: Some(2),
numbering: Some(NumRef { num: 0, level: 0 }),
tabs: Some(vec![
TabStop { pos: 72.0, align: TabAlign::Center, leader: TabLeader::Dot },
TabStop { pos: 144.0, align: TabAlign::Right, leader: TabLeader::None },
TabStop { pos: 200.0, align: TabAlign::Decimal, leader: TabLeader::Underscore },
TabStop { pos: 216.0, align: TabAlign::Clear, leader: TabLeader::None },
TabStop { pos: 250.0, align: TabAlign::Bar, leader: TabLeader::MiddleDot },
]),
shading: Some(Rgb(0xDD, 0xEE, 0xFF)),
borders: Some(Borders { top: Some(b), left: Some(Border::single(0.5)), bottom: Some(b), right: None, between: Some(b), inside_v: None }),
suppress_hyphens: Some(true),
suppress_line_numbers: Some(true),
bidi: Some(false),
drop_cap: None,
};
let variants = [
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
ParaProps { line_spacing: Some(LineSpacing::Exactly(20.0)), align: Some(Align::Right), ..Default::default() },
ParaProps { align: Some(Align::Distribute), ..Default::default() },
ParaProps::default(),
];
let mut ps = vec![Paragraph::with_text("main", CharProps::default())];
ps[0].props = pp.clone();
ps[0].mark = CharProps { bold: Some(true), size: Some(14.0), ..Default::default() };
for v in &variants {
let mut p = Paragraph::with_text("v", CharProps::default());
p.props = v.clone();
ps.push(p);
}
let r = rt(&doc_with(ps));
let got = paras(&r);
assert_eq!(got[0].props, pp);
assert_eq!(got[0].mark, CharProps { bold: Some(true), size: Some(14.0), ..Default::default() });
for (i, v) in variants.iter().enumerate() {
assert_eq!(&got[i + 1].props, v, "variant {i}");
}
}
#[test]
fn styles_and_lists_round_trip() {
let mut d = Document::new();
d.styles.upsert(Style {
id: "MyStyle".into(),
name: "My Style".into(),
kind: StyleKind::Paragraph,
based_on: Some("Normal".into()),
next: Some("Normal".into()),
linked: Some("MyStyleChar".into()),
para: ParaProps { space_after: Some(3.0), align: Some(Align::Center), ..Default::default() },
chr: CharProps { italic: Some(true), color: Some(TextColor::Rgb(Rgb(1, 2, 3))), ..Default::default() },
priority: Some(5),
quick: true,
hidden: false,
builtin: false,
table: None,
});
d.styles.upsert(Style {
id: "MyStyleChar".into(),
name: "My Style Char".into(),
kind: StyleKind::Character,
linked: Some("MyStyle".into()),
chr: CharProps { italic: Some(true), ..Default::default() },
hidden: true,
..Default::default()
});
let bullets = d.numbering.add_list(ListKind::Bullet);
let nums = d.numbering.add_list(ListKind::Legal);
let outline = d.numbering.add_list(ListKind::Outline);
if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == outline) {
n.start_overrides.push((0, 4));
}
let mut ps = Vec::new();
for (i, num) in [bullets, nums, nums, outline].iter().enumerate() {
let mut p = Paragraph::with_text(&format!("item {i}"), CharProps::default());
p.props.numbering = Some(NumRef { num: *num, level: (i % 2) as u8 });
p.props.style = Some("MyStyle".into());
ps.push(p);
}
d.body = ps.into_iter().map(para_block).collect();
let r = rt(&d);
assert_eq!(r.numbering, d.numbering);
assert_eq!(r.styles.default_chr, d.styles.default_chr);
assert_eq!(r.styles.default_para, d.styles.default_para);
for s in &d.styles.styles {
assert_eq!(r.styles.get(&s.id), Some(s), "style {}", s.id);
}
assert_eq!(r.styles.styles.len(), d.styles.styles.len());
assert_eq!(paras(&r)[1].props.numbering, Some(NumRef { num: nums, level: 1 }));
}
#[test]
fn tables_with_merges_round_trip() {
let mut t = Table::new(3, 3, 468.0);
t.props.width = Some(468.0);
t.props.align = Some(Align::Center);
t.props.indent = Some(5.0);
t.props.borders = Some(Borders::all(Border::single(0.5)));
t.props.cell_margins = Some([1.0, 5.4, 1.0, 5.4]);
t.props.fixed = true;
t.props.look = TableLook { header_row: true, total_row: true, banded_rows: false, first_column: false, last_column: true, banded_columns: true };
t.props.shading = Some(Rgb(1, 1, 1));
t.props.caption = Some("Sales".into());
t.rows[0].props = RowProps { height: Some(20.0), height_rule: HeightRule::Exact, header: true, cant_split: true };
t.rows[1].props = RowProps { height: Some(15.0), height_rule: HeightRule::AtLeast, ..Default::default() };
t.merge(0, 0, 0, 1); // horizontal
t.merge(1, 2, 2, 2); // vertical
{
let c = &mut t.rows[1].cells[0];
c.props.shading = Some(Rgb(0xFF, 0, 0));
c.props.valign = VAlign::Bottom;
c.props.text_direction = TextDirection::Up;
c.props.no_wrap = true;
c.props.margins = Some([2.0, 3.0, 4.0, 5.0]);
c.props.borders = Some(Borders::box_(Border { style: BorderStyle::Dashed, width: 1.0, color: Some(Rgb(0, 0, 255)), space: 0.0 }));
c.blocks = vec![para_block(Paragraph::with_text("red", CharProps::default()))];
}
// Nested table in a cell (followed by the required paragraph).
let inner = Table::new(1, 2, 100.0);
t.rows[2].cells[0].blocks = vec![Arc::new(Block::Table(inner)), para_block(Paragraph::new())];
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text("before", CharProps::default())), Arc::new(Block::Table(t.clone())), para_block(Paragraph::new())];
let r = rt(&d);
let got = r.body[1].as_table().expect("table");
assert_eq!(got.props, t.props);
assert_eq!(got.grid, t.grid);
assert_eq!(got.rows.len(), 3);
for (gr, er) in got.rows.iter().zip(&t.rows) {
assert_eq!(gr.props, er.props);
assert_eq!(gr.cells.len(), er.cells.len());
for (gc, ec) in gr.cells.iter().zip(&er.cells) {
assert_eq!(gc.props, ec.props);
assert_eq!(gc.blocks, ec.blocks);
}
}
assert_eq!(got.rows[0].cells[0].span(), 2);
assert_eq!(got.rows[2].cells[2].props.vmerge, VMerge::Continue);
}
#[test]
fn sections_headers_footers_round_trip() {
let mut d = Document::new();
let page = |instr: &str| {
let mut p = Paragraph::with_text("Page ", CharProps::default());
p.insert_object(5, InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false }, &CharProps::default()).unwrap();
p
};
let h1 = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Header one", CharProps::default()))]);
let hf = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("First page header", CharProps::default()))]);
let f1 = d.add_part(PartKind::Footer, vec![para_block(page("PAGE"))]);
let f2 = d.add_part(PartKind::Footer, vec![para_block(page("NUMPAGES"))]);
let mut s1 = SectionProps {
title_page: true,
page_num_start: Some(3),
page_num_format: NumFormat::UpperRoman,
valign: VAlign::Center,
page_borders: Some(Borders::box_(Border::single(1.0))),
line_numbers: Some(LineNumbering { count_by: 5, start: 1, distance: 18.0, restart: LineNumberRestart::Section }),
gutter: 18.0,
..Default::default()
};
s1.headers.default = Some(h1);
s1.headers.first = Some(hf);
s1.footers.default = Some(f1);
let mut s2 = SectionProps { start: SectionStart::OddPage, rtl: true, ..Default::default() };
s2.set_landscape(true);
s2.columns = Columns { count: 2, space: 24.0, separator: true, widths: Vec::new() };
s2.footers.default = Some(f2);
s2.headers.default = Some(h1);
let mut p1 = Paragraph::with_text("Section one", CharProps::default());
p1.section = Some(Box::new(s1.clone()));
let mut s3 = SectionProps { start: SectionStart::Continuous, ..Default::default() };
s3.columns = Columns { count: 2, space: 10.0, separator: false, widths: vec![(200.0, 20.0), (248.0, 0.0)] };
let mut p2 = Paragraph::with_text("Section two", CharProps::default());
p2.section = Some(Box::new(s2.clone()));
d.body = vec![para_block(p1), para_block(p2), para_block(Paragraph::with_text("Last", CharProps::default()))];
d.last_section = s3.clone();
let r = rt(&d);
let secs = r.sections();
assert_eq!(secs.len(), 3);
let strip = |s: &SectionProps| SectionProps { headers: Default::default(), footers: Default::default(), ..s.clone() };
assert_eq!(strip(secs[0].1), strip(&s1));
assert_eq!(strip(secs[1].1), strip(&s2));
assert_eq!(strip(secs[2].1), strip(&s3));
assert!(secs[1].1.landscape);
assert_eq!(part_text(&r, secs[0].1.headers.default), "Header one");
assert_eq!(part_text(&r, secs[0].1.headers.first), "First page header");
assert_eq!(secs[0].1.headers.default, secs[1].1.headers.default, "shared header part stays shared");
let fp = r.parts.get(&secs[0].1.footers.default.unwrap()).unwrap().blocks[0].as_para().unwrap();
assert_eq!(fp.objects, vec![InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }]);
assert_eq!(part_text(&r, secs[1].1.footers.default), "Page 1");
assert_eq!(r.parts.get(&secs[0].1.headers.default.unwrap()).unwrap().kind, PartKind::Header);
}
#[test]
fn images_round_trip() {
let mut d = Document::new();
let png = tiny_png();
let key = d.add_media(png.clone(), "png");
let inline = InlineObject::Image {
media: key.clone(),
w: 72.0,
h: 48.0,
alt: "A tiny picture".into(),
// Room for a shadow: kept through save and load.
float: Float { effect: [12.0, 12.0, 27.0, 27.0], ..Default::default() },
crop: [0.1, 0.0, 0.25, 0.05],
};
let floating = InlineObject::Image {
media: key.clone(),
w: 100.0,
h: 50.0,
alt: String::new(),
float: Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Margin, x: 36.0, y: 12.5, dist: 9.0, ..Default::default() },
crop: [0.0; 4],
};
let aligned = InlineObject::Image {
media: key.clone(),
w: 20.0,
h: 20.0,
alt: String::new(),
float: Float {
wrap: Wrap::Square,
h_rel: Anchor::RightMargin,
h_align: Some(FloatAlign::Center),
v_rel: Anchor::TopMargin,
v_align: Some(FloatAlign::End),
dist: 9.0,
dist_top: 2.5,
dist_bottom: 4.0,
..Default::default()
},
crop: [0.0; 4],
};
let mut floats = vec![floating.clone(), aligned];
for wrap in [Wrap::Tight, Wrap::Through, Wrap::TopAndBottom, Wrap::BehindText, Wrap::InFrontOfText] {
floats.push(InlineObject::Image {
media: key.clone(),
w: 10.0,
h: 10.0,
alt: String::new(),
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 0.0, y: 0.0, dist: 0.0, ..Default::default() },
crop: [0.0; 4],
});
}
let mut p = Paragraph::with_text("pic: ", CharProps::default());
p.insert_object(5, inline.clone(), &CharProps::default()).unwrap();
for f in &floats {
let end = p.len();
p.insert_object(end, f.clone(), &CharProps::default()).unwrap();
}
d.body = vec![para_block(p)];
let r = rt(&d);
let got = paras(&r)[0];
let mut expect = vec![inline];
expect.extend(floats);
assert_eq!(got.objects, expect);
assert_eq!(r.media.get(&key).map(|b| b.as_slice()), Some(png.as_slice()));
assert_eq!(r.media.len(), 1);
}
#[test]
fn hyperlinks_and_bookmarks_round_trip() {
let ext = CharProps { link: Some("https://example.com/a?b=1&c=2".into()), underline: Some(Underline::Single), ..Default::default() };
let int = CharProps { link: Some("#target".into()), ..Default::default() };
let mut p = para_runs(&[("see ", CharProps::default()), ("example", ext.clone()), (" and ", CharProps::default()), ("here", int.clone())]);
p.insert_object(0, InlineObject::BookmarkStart { name: "target".into() }, &CharProps::default()).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::BookmarkEnd { name: "target".into() }, &CharProps::default()).unwrap();
let d = doc_with(vec![p.clone()]);
let r = rt(&d);
let got = paras(&r)[0];
assert_eq!(got.text, p.text);
assert_eq!(got.objects, p.objects);
assert_eq!(got.props_of_char(got.text.find("example").unwrap()), &ext);
assert_eq!(got.props_of_char(got.text.find("here").unwrap()), &int);
assert_eq!(r.bookmarks().len(), 1);
}
#[test]
fn comments_round_trip() {
let mut d = Document::new();
let c0 = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Please check this.", CharProps::default()))]);
let c1 = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Done.", CharProps::default()))]);
d.comments.insert(
0,
Comment { author: "Ann".into(), initials: "A".into(), date: "2026-01-02T03:04:05Z".into(), parent: None, resolved: true, part: c0 },
);
d.comments.insert(
1,
Comment { author: "Ben".into(), initials: "B".into(), date: "2026-01-03T00:00:00Z".into(), parent: Some(0), resolved: false, part: c1 },
);
let mut p = Paragraph::with_text("commented text", CharProps::default());
p.insert_object(0, InlineObject::CommentStart { id: 0 }, &CharProps::default()).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::CommentEnd { id: 0 }, &CharProps::default()).unwrap();
d.body = vec![para_block(p.clone())];
let r = rt(&d);
assert_eq!(paras(&r)[0].objects, p.objects);
assert_eq!(r.comments.len(), 2);
for (id, c) in &d.comments {
let g = r.comments.get(id).expect("comment");
assert_eq!((&g.author, &g.initials, &g.date, g.parent, g.resolved), (&c.author, &c.initials, &c.date, c.parent, c.resolved));
assert_eq!(part_text(&r, Some(g.part)), part_text(&d, Some(c.part)));
assert_eq!(r.parts.get(&g.part).unwrap().kind, PartKind::Comment);
}
}
/// A table style's cell text formatting, whole-table shading, cell margins and its header row and
/// row band conditional formats (#146) survive a save.
#[test]
fn table_style_formatting_round_trips() {
use wordcraft_doc::styles::TableStyleParts;
let mut d = doc_with(vec![Paragraph::with_text("x", CharProps::default())]);
let parts = TableStyleParts {
borders: Some(Borders { top: Some(Border::single(1.0)), ..Default::default() }),
fill: Some(Rgb(0xDD, 0xEB, 0xF7)),
cell_margins: Some([1.0, 14.4, 0.0, 14.4]),
header_chr: CharProps { italic: Some(true), ..Default::default() },
header_fill: Some(Rgb(0xFF, 0xFF, 0)),
header_borders: Some(Borders::all(Border::single(1.5))),
band_fill: Some(Rgb(0xF2, 0xF2, 0xF2)),
band_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0, 0x70, 0xC0))), ..Default::default() },
band_borders: Some(Borders::box_(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })),
band_size: Some(3),
..Default::default()
};
d.styles.upsert(Style {
id: "Whole".into(),
name: "Whole".into(),
kind: StyleKind::Table,
based_on: Some("TableGrid".into()),
para: ParaProps { align: Some(Align::Center), space_after: Some(0.0), ..Default::default() },
chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0xC0, 0, 0))), ..Default::default() },
table: Some(parts.clone()),
..Default::default()
});
let r = rt(&d);
let got = r.styles.get("Whole").unwrap();
assert_eq!(got.based_on.as_deref(), Some("TableGrid"));
assert_eq!(got.para.align, Some(Align::Center));
assert_eq!(got.para.space_after, Some(0.0));
assert_eq!((got.chr.bold, got.chr.color), (Some(true), Some(TextColor::Rgb(Rgb(0xC0, 0, 0)))));
assert_eq!(got.table.as_ref(), Some(&parts));
}
#[test]
fn notes_round_trip() {
let mut d = Document::new();
let f = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("A footnote.", CharProps::default()))]);
let f2 = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Custom.", CharProps::default()))]);
let e = d.add_part(
PartKind::Endnote,
vec![
para_block(Paragraph::with_text("An endnote.", CharProps::default())),
para_block(Paragraph::with_text("Second para.", CharProps::default())),
],
);
let mut p = Paragraph::with_text("Text", CharProps::default());
let sup = CharProps { vert_align: Some(VertAlign::Superscript), ..Default::default() };
p.insert_object(4, InlineObject::NoteRef { kind: NoteKind::Footnote, id: f, custom: String::new() }, &sup).unwrap();
p.insert_object(1, InlineObject::NoteRef { kind: NoteKind::Endnote, id: e, custom: String::new() }, &sup).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::NoteRef { kind: NoteKind::Footnote, id: f2, custom: "*".into() }, &sup).unwrap();
d.settings.footnote_format = NumFormat::LowerLetter;
d.settings.endnote_format = NumFormat::UpperRoman;
d.body = vec![para_block(p.clone())];
let r = rt(&d);
let got = paras(&r)[0];
assert_eq!(got.text, p.text);
assert_eq!(got.runs, p.runs);
let notes: Vec<(NoteKind, String, String)> = got
.objects
.iter()
.map(|o| match o {
InlineObject::NoteRef { kind, id, custom } => (*kind, part_text(&r, Some(*id)), custom.clone()),
o => panic!("unexpected {o:?}"),
})
.collect();
assert_eq!(
notes,
vec![
(NoteKind::Endnote, "An endnote.\nSecond para.".into(), String::new()),
(NoteKind::Footnote, "A footnote.".into(), String::new()),
(NoteKind::Footnote, "Custom.".into(), "*".into()),
]
);
assert_eq!(r.settings.footnote_format, NumFormat::LowerLetter);
assert_eq!(r.settings.endnote_format, NumFormat::UpperRoman);
}
/// `references.footnote` starts the note's own text with a reference to the note, so the editor shows
/// its number. In the file that is the `w:footnoteRef` mark; a `w:footnoteReference` there would make
/// the note cite itself, which LibreOffice refuses to open.
#[test]
fn note_never_references_itself() {
let mut d = Document::new();
let f = d.add_part(PartKind::Footnote, Vec::new());
let mut note = Paragraph::with_text(" The note.", CharProps::default());
note.insert_object(0, InlineObject::NoteRef { kind: NoteKind::Footnote, id: f, custom: String::new() }, &CharProps::default()).unwrap();
d.parts.get_mut(&f).unwrap().blocks = vec![para_block(note)];
let mut p = Paragraph::with_text("Text", CharProps::default());
p.insert_object(4, InlineObject::NoteRef { kind: NoteKind::Footnote, id: f, custom: String::new() }, &CharProps::default()).unwrap();
d.body = vec![para_block(p)];
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/footnotes.xml").unwrap(), &mut xml).unwrap();
assert!(!xml.contains("<w:footnoteReference"), "a footnote cites itself: {xml}");
assert_eq!(xml.matches("<w:footnoteRef/>").count(), 1, "{xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
let got = paras(&r)[0];
let Some(InlineObject::NoteRef { id, .. }) = got.objects.first() else { panic!("{:?}", got.objects) };
assert_eq!(part_text(&r, Some(*id)).trim(), "The note.");
}
/// A note read from a Word file starts with its own number (a reference to itself, from the
/// `w:footnoteRef` mark). Saving writes that mark back exactly once, with its character style, and
/// never as a `w:footnoteReference`; reading the saved file gives the same note again.
#[test]
fn word_note_mark_round_trips_once() {
let mut d = Document::new();
let f = d.add_part(PartKind::Footnote, Vec::new());
let e = d.add_part(PartKind::Endnote, Vec::new());
for (id, kind, style) in [(f, NoteKind::Footnote, "FootnoteReference"), (e, NoteKind::Endnote, "EndnoteReference")] {
let mut note =
Paragraph::with_text(" The note.", CharProps::default()).styled(if kind == NoteKind::Footnote { "FootnoteText" } else { "EndnoteText" });
note.insert_object(
0,
InlineObject::NoteRef { kind, id, custom: String::new() },
&CharProps { style: Some(style.into()), ..Default::default() },
)
.unwrap();
// A second paragraph: the mark is not repeated there.
d.parts.get_mut(&id).unwrap().blocks = vec![para_block(note), para_block(Paragraph::with_text("More.", CharProps::default()))];
}
let mut p = Paragraph::with_text("Text", CharProps::default());
p.insert_object(4, InlineObject::NoteRef { kind: NoteKind::Footnote, id: f, custom: String::new() }, &CharProps::default()).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::NoteRef { kind: NoteKind::Endnote, id: e, custom: String::new() }, &CharProps::default()).unwrap();
d.body = vec![para_block(p)];
let mut doc = d;
for pass in 0..2 {
let bytes = wordcraft_docx::write(&doc).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
for (file, mark, reference, style) in [
("word/footnotes.xml", "<w:footnoteRef/>", "<w:footnoteReference", "FootnoteReference"),
("word/endnotes.xml", "<w:endnoteRef/>", "<w:endnoteReference", "EndnoteReference"),
] {
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name(file).unwrap(), &mut xml).unwrap();
assert_eq!(xml.matches(mark).count(), 1, "pass {pass}: {xml}");
assert!(!xml.contains(reference), "pass {pass}: a note cites itself: {xml}");
assert!(xml.contains(&format!(r#"<w:rStyle w:val="{style}"/></w:rPr>{mark}"#)), "pass {pass}: {xml}");
}
let r = wordcraft_docx::read(&bytes).expect("read");
let body = paras(&r)[0];
assert_eq!(body.objects.len(), 2, "pass {pass}");
for o in &body.objects {
let InlineObject::NoteRef { kind, id, .. } = o else { panic!("{o:?}") };
let blocks = &r.parts.get(id).unwrap().blocks;
assert_eq!(blocks.len(), 2, "pass {pass}");
let first = blocks[0].as_para().unwrap();
assert_eq!(first.objects, vec![InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() }], "pass {pass}");
assert_eq!(first.plain_text(), " The note.");
assert!(blocks[1].as_para().unwrap().objects.is_empty(), "pass {pass}");
}
doc = r;
}
}
/// The TOC is a `Contents` paragraph ending in an empty `TOC` field, followed by TOC-styled entries.
/// In the file the field must contain the entries, inside Word's Table of Contents content control,
/// or Word shows them as plain text with no Update Table.
#[test]
fn toc_field_wraps_its_entries() {
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
let end = head.len();
head.insert_object(
end,
InlineObject::Field { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false },
&CharProps::default(),
)
.unwrap();
let entries = [("Intro\t1", "TOC1"), ("Detail\t2", "TOC2")];
let mut blocks = vec![head];
blocks.extend(entries.iter().map(|(t, st)| Paragraph::with_text(t, CharProps::default()).styled(st)));
blocks.push(Paragraph::with_text("Body", CharProps::default()));
let d = doc_with(blocks);
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
let sdt = xml.find("<w:sdt>").expect("TOC content control");
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "{xml}");
let (begin, fend) = (xml.find(r#"fldCharType="begin""#).unwrap(), xml.find(r#"fldCharType="end""#).unwrap());
let last_entry = xml.find("Detail").unwrap();
assert!(sdt < xml.find("Contents").unwrap() && xml.find("</w:sdt>").unwrap() < xml.find(">Body<").unwrap(), "{xml}");
assert!(begin < xml.find("Intro").unwrap() && fend > last_entry && fend < xml.find("</w:sdt>").unwrap(), "field must span the entries: {xml}");
// Word starts the field in the first entry; started in the heading, LibreOffice splits the heading.
let heading_end = xml[..xml.find("Intro").unwrap()].rfind("</w:p>").unwrap();
assert!(begin > heading_end, "field must start in the first entry, not the heading: {xml}");
// WordCraft reads its own TOC back unchanged.
let r = wordcraft_docx::read(&bytes).expect("read");
let ps = paras(&r);
assert_eq!(ps.iter().map(|p| p.plain_text()).collect::<Vec<_>>(), ["Contents", "Intro\t1", "Detail\t2", "Body"]);
assert!(matches!(ps[0].objects.first(), Some(InlineObject::Field { instr, .. }) if instr.starts_with("TOC")), "{:?}", ps[0].objects);
assert!(ps[1].objects.is_empty(), "the field goes back on the heading: {:?}", ps[1].objects);
assert_eq!(ps[1].props.style.as_deref(), Some("TOC1"));
}
/// Table Layout › Text Direction (#226): each direction is written as Word's `w:textDirection`
/// value and read back.
#[test]
fn cell_text_direction_round_trips() {
let mut t = Table::new(1, 3, 300.0);
let dirs = [TextDirection::Horizontal, TextDirection::Down, TextDirection::Up];
for (c, dir) in t.rows[0].cells.iter_mut().zip(dirs) {
c.props.text_direction = dir;
}
let mut d = Document::new();
d.body = vec![Arc::new(Block::Table(t)), para_block(Paragraph::new())];
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"<w:textDirection w:val="tbRl"/>"#), "{xml}");
assert!(xml.contains(r#"<w:textDirection w:val="btLr"/>"#), "{xml}");
assert_eq!(xml.matches("w:textDirection").count(), 2, "horizontal cells write nothing: {xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
let got: Vec<TextDirection> = r.body[0].as_table().expect("table").rows[0].cells.iter().map(|c| c.props.text_direction).collect();
assert_eq!(got, dirs);
}
/// The TOC field's begin and end belong to the entries' own paragraphs, even when the heading or
/// an entry holds a text box: the box's paragraphs are written inside them and must not take either.
#[test]
fn toc_field_skips_nested_stories() {
let mut d = Document::new();
let text_box = |d: &mut Document, text: &str| {
let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, CharProps::default()))]);
InlineObject::Shape {
kind: ShapeKind::TextBox,
w: 72.0,
h: 36.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Float::default(),
story: Some(story),
}
};
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
let end = head.len();
head.insert_object(
end,
InlineObject::Field { instr: "TOC \\o \"1-1\" \\h \\z \\u".into(), result: String::new(), locked: false },
&CharProps::default(),
)
.unwrap();
let end = head.len();
head.insert_object(end, text_box(&mut d, "Heading box"), &CharProps::default()).unwrap();
let mut last = Paragraph::with_text("Detail\t2", CharProps::default()).styled("TOC1");
let end = last.len();
last.insert_object(end, text_box(&mut d, "Boxed"), &CharProps::default()).unwrap();
d.body = vec![para_block(head), para_block(last), para_block(Paragraph::with_text("Body", CharProps::default()))];
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
let boxes: Vec<(usize, usize)> = xml.match_indices("<w:txbxContent>").map(|(a, _)| (a, a + xml[a..].find("</w:txbxContent>").unwrap())).collect();
assert_eq!(boxes.len(), 2, "{xml}");
let in_box = |i: usize| boxes.iter().any(|(a, b)| (*a..*b).contains(&i));
for kind in ["begin", "separate", "end"] {
let at: Vec<usize> = xml.match_indices(&format!(r#"fldCharType="{kind}""#)).map(|(i, _)| i).collect();
assert_eq!(at.len(), 1, "{kind}: {xml}");
assert!(!in_box(at[0]), "field {kind} written inside a text box: {xml}");
}
let (begin, end) = (xml.find(r#"fldCharType="begin""#).unwrap(), xml.find(r#"fldCharType="end""#).unwrap());
assert!(begin > boxes[0].1 && end > boxes[1].1 && end < xml.find("</w:sdt>").unwrap(), "{xml}");
}
#[test]
fn tracked_changes_round_trip() {
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into() });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into() });
d.settings.track_changes = true;
let ins = CharProps { ins: Some(0), ..Default::default() };
let del = CharProps { del: Some(1), bold: Some(true), ..Default::default() };
let both = CharProps { ins: Some(0), del: Some(1), ..Default::default() };
let p = para_runs(&[("kept ", CharProps::default()), ("added\t", ins.clone()), ("removed", del.clone()), ("gone", both.clone())]);
let mut p = p;
let end = p.len();
p.insert_object(end, InlineObject::Field { instr: "DATE".into(), result: "today".into(), locked: false }, &del).unwrap();
d.body = vec![para_block(p.clone())];
let r = rt(&d);
assert_eq!(r.revisions, d.revisions);
let got = paras(&r)[0];
assert_eq!(got.text, p.text);
assert_eq!(got.runs, p.runs);
assert_eq!(got.objects, p.objects);
assert!(r.settings.track_changes);
}
#[test]
fn tracked_paragraph_marks_round_trip() {
// Issue #229: an inserted / deleted paragraph mark is `w:ins` / `w:del` in the mark's
// `w:rPr` (ECMA-376 §17.13.5.16, §17.13.5.15), alone or with mark formatting.
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into() });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into() });
let mut split = Paragraph::with_text("Owned ALPHA ", CharProps::default());
split.mark.ins = Some(0);
let mut joined = Paragraph::with_text("Owned BETA", CharProps::default());
joined.mark = CharProps { bold: Some(true), del: Some(1), ..Default::default() };
let last = Paragraph::with_text("plain", CharProps::default());
d.body = vec![para_block(split), para_block(joined), para_block(last)];
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"<w:rPr><w:ins w:id="#), "{xml}");
assert!(xml.contains(r#"<w:rPr><w:del w:id="#), "{xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
let got = paras(&r);
assert_eq!(got.len(), 3);
let rev = |i: Option<u32>| i.and_then(|i| r.revisions.get(i as usize)).map(|v| (v.kind, v.author.as_str(), v.date.as_str()));
assert_eq!(rev(got[0].mark.ins), Some((RevisionKind::Insert, "Alice", "2026-05-01T10:00:00Z")));
assert_eq!(got[0].mark.del, None);
assert_eq!(rev(got[1].mark.del), Some((RevisionKind::Delete, "Bob", "2026-05-02T10:00:00Z")));
assert_eq!(got[1].mark.bold, Some(true));
assert_eq!(got[1].mark.ins, None);
assert_eq!((got[2].mark.ins, got[2].mark.del), (None, None));
assert_eq!(got.iter().map(|p| p.text.as_str()).collect::<Vec<_>>(), ["Owned ALPHA ", "Owned BETA", "plain"]);
}
#[test]
fn fields_and_special_chars_round_trip() {
let mut p = Paragraph::with_text("a\tb\nc\u{000C}d\u{000E}e\u{2011}f\u{00AD}g spaced ", CharProps::default());
let fields = [
InlineObject::Field { instr: "PAGE".into(), result: "4".into(), locked: false },
InlineObject::Field { instr: "DATE \\@ \"M/d/yyyy\"".into(), result: "1/2/2026".into(), locked: true },
InlineObject::Field { instr: "NUMPAGES".into(), result: String::new(), locked: false },
];
for f in &fields {
let end = p.len();
p.insert_object(end, f.clone(), &CharProps { bold: Some(true), ..Default::default() }).unwrap();
}
let r = rt(&doc_with(vec![p.clone()]));
let got = paras(&r)[0];
assert_eq!(got.text, p.text);
assert_eq!(got.objects, p.objects);
assert_eq!(got.runs, p.runs);
}
#[test]
fn shapes_textboxes_equations_dropcaps_round_trip() {
let mut d = Document::new();
let story =
d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Inside the box", CharProps { bold: Some(true), ..Default::default() }))]);
let tb = InlineObject::Shape {
kind: ShapeKind::TextBox,
w: 144.0,
h: 72.0,
fill: Some(Rgb(255, 255, 200)),
stroke: Some(Rgb(0, 0, 0)),
stroke_width: 1.0,
float: Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: 10.0, y: 20.0, dist: 0.0, ..Default::default() },
story: Some(story),
};
let star = InlineObject::Shape {
kind: ShapeKind::Star,
w: 20.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Float::default(),
story: None,
};
let eq = InlineObject::Equation { linear: "x=(-b±√(b^2-4ac))/2a".into(), display: false, math: Default::default() };
let mut p = Paragraph::with_text("shapes ", CharProps::default());
for o in [tb, star.clone(), eq.clone()] {
let end = p.len();
p.insert_object(end, o, &CharProps::default()).unwrap();
}
let mut dc = Paragraph::with_text("Once upon a time", CharProps::default());
dc.props.drop_cap = Some(3);
dc.props.space_after = Some(4.0);
d.body = vec![para_block(p.clone()), para_block(dc.clone())];
let r = rt(&d);
let got = paras(&r);
assert_eq!(got[0].objects.len(), 3);
match &got[0].objects[0] {
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story } => {
assert_eq!(
(*kind, *w, *h, *fill, *stroke, *stroke_width),
(ShapeKind::TextBox, 144.0, 72.0, Some(Rgb(255, 255, 200)), Some(Rgb(0, 0, 0)), 1.0)
);
assert_eq!(float.wrap, Wrap::Square);
assert_eq!(part_text(&r, *story), "Inside the box");
}
o => panic!("{o:?}"),
}
assert_eq!(got[0].objects[1], star);
match &got[0].objects[2] {
InlineObject::Equation { linear, display, math } => {
assert_eq!(linear, "x=(-b±√(b^2-4ac))/2a");
assert!(!display);
assert_eq!(math.nodes, wordcraft_doc::math::parse_linear(linear), "structure is written as OMML and read back");
}
o => panic!("{o:?}"),
}
assert_eq!(got.len(), 2, "drop cap paragraph merges back");
assert_eq!(got[1].text, dc.text);
assert_eq!(got[1].props, dc.props);
}
#[test]
fn compatibility_mode_round_trips() {
// New documents are Word 2013+ documents; an older file keeps its mode, so saving it doesn't
// change how Word lays it out.
assert_eq!(rt(&Document::new()).settings.compat_mode, wordcraft_doc::COMPAT_MODE_CURRENT);
for mode in [11, 12, 14, 15] {
let mut d = Document::new();
d.settings.compat_mode = mode;
assert_eq!(rt(&d).settings.compat_mode, mode);
}
}
#[test]
fn settings_core_theme_round_trip() {
let mut d = Document::new();
d.settings.track_changes = true;
d.settings.default_tab = 18.0;
d.settings.even_odd_headers = true;
d.settings.mirror_margins = true;
d.settings.auto_hyphenation = true;
d.settings.page_color = Some(Rgb(250, 240, 230));
d.settings.major_font = "Cambria".into();
d.settings.minor_font = "Calibri".into();
d.settings.theme_colors[4] = Rgb(1, 2, 3);
d.settings.theme_name = "Mine".into();
d.settings.protection = Some("readOnly".into());
d.core.title = "Title & <stuff>".into();
d.core.subject = "Subj".into();
d.core.creator = "Me".into();
d.core.keywords = "a, b".into();
d.core.description = "Desc".into();
d.core.category = "Cat".into();
d.core.last_modified_by = "You".into();
d.core.created = "2026-01-01T00:00:00Z".into();
d.core.modified = "2026-02-01T00:00:00Z".into();
d.core.revision = 7;
let r = rt(&d);
assert_eq!(r.settings, d.settings);
assert_eq!(r.core, d.core);
// Watermarks aren't stored in DOCX by this crate (yet).
let mut w = d.clone();
w.settings.watermark = Some(Watermark::default());
assert_eq!(rt(&w).settings.watermark, None);
}
/// A document that uses everything, for second-generation stability checks.
fn kitchen_sink() -> Document {
let mut d = Document::new();
let png = tiny_png();
let key = d.add_media(png, "png");
let num = d.numbering.add_list(ListKind::Numbered);
let h = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
let f = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Note", CharProps::default()))]);
let c = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Comment", CharProps::default()))]);
d.comments.insert(3, Comment { author: "X".into(), part: c, ..Default::default() });
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "A".into(), date: String::new() });
d.last_section.headers.default = Some(h);
let mut blocks: Blocks = Vec::new();
let mut p = para_runs(&[
("Hello ", CharProps::default()),
("bold", CharProps { bold: Some(true), ..Default::default() }),
(" link", CharProps { link: Some("https://x.org".into()), ..Default::default() }),
]);
p.insert_object(0, InlineObject::CommentStart { id: 3 }, &CharProps::default()).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::CommentEnd { id: 3 }, &CharProps::default()).unwrap();
let at = p.text.find("bold").unwrap();
p.insert_object(at, InlineObject::NoteRef { kind: NoteKind::Footnote, id: f, custom: String::new() }, &CharProps::default()).unwrap();
blocks.push(para_block(p));
let mut li = Paragraph::with_text("listed", CharProps { ins: Some(0), ..Default::default() });
li.props.numbering = Some(NumRef { num, level: 0 });
blocks.push(para_block(li));
let mut img = Paragraph::new();
img.insert_object(
0,
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "pic".into(), float: Float::default(), crop: [0.0; 4] },
&CharProps::default(),
)
.unwrap();
blocks.push(para_block(img));
let mut t = Table::new(2, 2, 300.0);
t.merge(0, 1, 1, 1);
t.rows[0].cells[0] = Cell::with_text("cell");
blocks.push(Arc::new(Block::Table(t)));
blocks.push(para_block(Paragraph::new()));
d.body = blocks;
d
}
#[test]
fn second_generation_is_stable() {
let d1 = rt(&kitchen_sink());
let d2 = rt(&d1);
assert_eq!(d2.body, d1.body);
assert_eq!(d2.parts, d1.parts);
assert_eq!(d2.styles, d1.styles);
assert_eq!(d2.numbering, d1.numbering);
assert_eq!(d2.comments, d1.comments);
assert_eq!(d2.revisions, d1.revisions);
assert_eq!(d2.settings, d1.settings);
assert_eq!(d2.media, d1.media);
assert_eq!(d2, d1);
}
#[test]
fn builtin_stylesheet_survives() {
let d = Document::new();
let r = rt(&d);
assert_eq!(r.styles, d.styles);
assert_eq!(r.body, d.body);
assert_eq!(r.last_section, d.last_section);
}
#[test]
fn hostile_model_values_still_write() {
let mut d = Document::new();
let mut p = Paragraph::with_text(
"x\u{1}\u{FFFE}y<&>\"'",
CharProps { size: Some(f32::NAN), spacing: Some(f32::INFINITY), font: Some("A\"B<".into()), ..Default::default() },
);
p.props.indent_left = Some(f32::NEG_INFINITY);
p.props.line_spacing = Some(LineSpacing::Multiple(f32::NAN));
p.insert_object(
0,
InlineObject::Opaque { format: "docx".into(), xml: "<w:r><w:t>raw</w:t></w:r>".into(), text: "raw".into() },
&CharProps::default(),
)
.unwrap();
p.insert_object(0, InlineObject::Opaque { format: "docx".into(), xml: "<broken".into(), text: "fallback".into() }, &CharProps::default())
.unwrap();
p.insert_object(
0,
InlineObject::Image { media: "missing.png".into(), w: -5.0, h: f32::NAN, alt: String::new(), float: Float::default(), crop: [f32::NAN; 4] },
&CharProps::default(),
)
.unwrap();
p.insert_object(0, InlineObject::CommentEnd { id: 99 }, &CharProps::default()).unwrap();
p.insert_object(0, InlineObject::NoteRef { kind: NoteKind::Endnote, id: 12345, custom: String::new() }, &CharProps::default()).unwrap();
d.body = vec![para_block(p)];
d.media.insert("weird name/../x".into(), Arc::new(b"GIF89a....".to_vec()));
d.media.insert("".into(), Arc::new(vec![0, 1, 2]));
let bytes = wordcraft_docx::write(&d).unwrap();
let r = wordcraft_docx::read(&bytes).unwrap();
let t = paras(&r)[0].plain_text();
assert!(t.contains("y<&>\"'"), "{t}");
assert!(t.contains("raw") && t.contains("fallback"), "{t}");
// Media nothing references aren't written.
assert!(r.media.is_empty());
}
#[test]
fn ensure_empty_and_table_end_document_is_valid() {
let mut d = Document::new();
d.body = vec![Arc::new(Block::Table(Table::new(1, 1, 100.0)))];
let r = rt(&d);
assert!(matches!(*r.body[0], Block::Table(_)));
assert!(matches!(**r.body.last().unwrap(), Block::Para(_)));
let mut e = Document::new();
e.body.clear();
let r = rt(&e);
assert_eq!(r.body.len(), 1);
}
#[test]
fn self_showing_text_box_saves_bounded() {
// Crafted: a box whose 30 shapes all show that same box. Unbounded, saving would nest it.
let mut d = Document::from_text("Body");
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Box", CharProps::default()))]);
let shape = || InlineObject::Shape {
kind: ShapeKind::TextBox,
w: 40.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Float::default(),
story: Some(id),
};
for _ in 0..30 {
d.insert_object(
&wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Part(id), path: wordcraft_doc::Path::top(0), off: 0 },
shape(),
&CharProps::default(),
)
.unwrap();
}
d.insert_object(&wordcraft_doc::Pos::body(0, 0), shape(), &CharProps::default()).unwrap();
let bytes = wordcraft_docx::write(&d).unwrap();
assert!(bytes.len() < 200_000, "{} bytes", bytes.len());
// It still opens, with the box's text.
let back = wordcraft_docx::read(&bytes).unwrap();
assert!(back.parts.values().any(|p| p.kind == PartKind::TextBox));
}
#[test]
fn char_border_written_between_u_and_shd() {
let c =
CharProps { border: Some(Border::single(0.5)), underline: Some(Underline::Single), shading: Some(Rgb(0xFF, 0xFF, 0)), ..Default::default() };
let bytes = wordcraft_docx::write(&doc_with(vec![para_runs(&[("x", c)])])).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
let bdr = r#"<w:bdr w:val="single" w:sz="4" w:space="0" w:color="auto"/>"#;
assert_eq!(xml.matches(bdr).count(), 1, "{xml}");
let at = xml.find(bdr).unwrap();
assert!(xml.find("<w:u ").unwrap() < at && at < xml.find("<w:shd ").unwrap(), "{xml}");
}
#[test]
fn floating_tables_round_trip() {
use wordcraft_doc::para::{Anchor, FloatAlign};
use wordcraft_doc::props::TableFloat;
let mut d = Document::new();
let mut t = Table::new(1, 2, 200.0);
let f = TableFloat {
h_rel: Anchor::Margin,
v_rel: Anchor::Paragraph,
x: 0.0,
y: -1.65,
h_align: None,
v_align: None,
dist: [9.0, 0.0, 12.0, 3.0],
overlap: false,
};
t.props.float = Some(f);
let mut page = Table::new(1, 1, 100.0);
let centred =
TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Margin, h_align: Some(FloatAlign::Center), y: 36.0, overlap: true, ..Default::default() };
page.props.float = Some(centred);
d.body = vec![Arc::new(Block::Table(t)), Arc::new(Block::Table(page)), para_block(Paragraph::with_text("after", CharProps::default()))];
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
let floats: Vec<_> = back.body.iter().filter_map(|b| if let Block::Table(t) = &**b { t.props.float } else { None }).collect();
assert_eq!(floats, [f, centred]);
}
#[test]
fn list_level_overrides_round_trip() {
use wordcraft_doc::numbering::{Counters, Level};
use wordcraft_doc::section::NumFormat;
let mut d = Document::new();
let num = d.numbering.add_list(ListKind::Numbered);
let restart = d.numbering.restart(num).unwrap();
let own = Level { format: NumFormat::DecimalZero, text: "%1.%2".into(), indent: 26.5, hanging: 26.5, ..Level::default() };
if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == restart) {
n.level_overrides = vec![(1, own.clone())];
}
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
let n = back.numbering.num(restart).unwrap();
assert_eq!(n.level_overrides.len(), 1);
let (lvl, got) = &n.level_overrides[0];
assert_eq!((*lvl, got.format, got.text.as_str(), got.indent, got.hanging), (1, NumFormat::DecimalZero, "%1.%2", 26.5, 26.5));
// The start overrides that `restart` wrote survive beside it.
assert_eq!(n.start_overrides, d.numbering.num(restart).unwrap().start_overrides);
let mut c = Counters::default();
assert_eq!(c.next_label(&back.numbering, restart, 0).unwrap().0, "1.");
assert_eq!(c.next_label(&back.numbering, restart, 1).unwrap().0, "1.01");
}