wordcraft/crates/docx/tests/roundtrip.rs

2032 lines
94 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, DocGrid, DocGridType, 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),
// WordArt effects ([MS-DOCX] w14 run properties).
text_effects: Some(Box::new(wordcraft_doc::wordart::TextEffects {
fill: Some(wordcraft_doc::wordart::TextFill::Gradient {
stops: vec![
wordcraft_doc::wordart::GradStop { pos: 0.0, color: Rgb(0x15, 0x60, 0x82), transparency: 0.0 },
wordcraft_doc::wordart::GradStop { pos: 100.0, color: Rgb(0xE9, 0x71, 0x32), transparency: 25.0 },
],
angle: 90.0,
}),
outline: Some(wordcraft_doc::wordart::TextOutline { color: Some(Rgb(0x0E, 0x28, 0x41)), width: 1.5, transparency: 0.0 }),
shadow: Some(wordcraft_doc::effects::Shadow {
color: Rgb::BLACK,
transparency: 60.0,
blur: 4.0,
distance: 3.0,
angle: 45.0,
rot_with_shape: false,
}),
glow: Some(wordcraft_doc::effects::Glow { color: Rgb(0x0F, 0x9E, 0xD5), size: 5.0, transparency: 40.0 }),
reflection: Some(wordcraft_doc::wordart::Reflection::default()),
})),
lang: Some("fr-FR".into()),
no_proof: Some(true),
rtl: Some(false),
cs: Some(true),
font_cs: Some("B Nazanin".into()),
font_ea: Some("宋体".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,
fmt_change: 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),
indent_left_chars: Some(1.5),
indent_right_chars: Some(0.0),
indent_first_chars: Some(-1.0),
space_before: Some(6.0),
space_after: Some(12.0),
line_spacing: Some(LineSpacing::Multiple(1.5)),
contextual_spacing: Some(true),
mirror_indents: 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,
kinsoku: Some(false),
word_wrap: Some(false),
overflow_punct: Some(false),
top_line_punct: Some(true),
auto_space_de: Some(false),
auto_space_dn: Some(true),
num_change: None,
fmt_change: None,
snap_to_grid: Some(false),
};
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 { kinsoku: Some(true), word_wrap: Some(true), auto_space_dn: Some(false), ..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,
locked: true,
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, fmt_change: None };
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.width = None;
c.props.width_pct = Some(40.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, code: 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,
// #391: a line and character grid (Word writes `w:docGrid` even without a grid).
doc_grid: Some(DocGrid { kind: DocGridType::LinesAndChars, line_pitch: 17.5, char_space: -1843 }),
..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, doc_grid: Some(DocGrid::default()), ..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, code: 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],
ole: None,
};
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],
ole: None,
};
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],
ole: None,
};
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],
ole: None,
});
}
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);
}
/// #385: footnote and endnote options round-trip for the document (settings) and per section
/// (`w:sectPr`), in schema order: `w:footnotePr` and `w:endnotePr` before `w:type`.
#[test]
fn note_options_round_trip_per_section() {
use wordcraft_doc::section::{NotePos, NoteProps, NoteRestart};
let mut d = Document::new();
d.settings.footnote_format = NumFormat::UpperLetter;
d.settings.footnote_pr =
NoteProps { pos: Some(NotePos::BeneathText), num_start: Some(4), num_restart: Some(NoteRestart::EachPage), ..Default::default() };
d.settings.endnote_pr = NoteProps { pos: Some(NotePos::SectEnd), ..Default::default() };
let mut first = Paragraph::with_text("Section one", CharProps::default());
let own = NoteProps { num_fmt: Some(NumFormat::LowerRoman), num_start: Some(7), num_restart: Some(NoteRestart::EachSect), ..Default::default() };
first.section = Some(Box::new(SectionProps {
footnote_pr: own,
endnote_pr: NoteProps { num_fmt: Some(NumFormat::Decimal), ..Default::default() },
..Default::default()
}));
d.body = vec![para_block(first), para_block(Paragraph::with_text("Section two", CharProps::default()))];
let bytes = wordcraft_docx::write(&d).unwrap();
let r = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(r.settings.footnote_format, NumFormat::UpperLetter);
assert_eq!((r.settings.footnote_pr, r.settings.endnote_pr), (d.settings.footnote_pr, d.settings.endnote_pr));
let sections = r.sections();
assert_eq!(sections[0].1.footnote_pr, own);
assert_eq!(sections[0].1.endnote_pr.num_fmt, Some(NumFormat::Decimal));
assert!(sections[1].1.footnote_pr.is_empty() && sections[1].1.endnote_pr.is_empty());
assert_eq!(r.note_options(sections[0].1, false).num_start, 7);
assert_eq!(r.note_options(sections[1].1, false).num_start, 4);
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 at = |s: &str| xml.find(s).unwrap_or_else(|| panic!("{s} missing"));
assert!(
at("<w:footnotePr><w:numFmt w:val=\"lowerRoman\"/><w:numStart w:val=\"7\"/><w:numRestart w:val=\"eachSect\"/></w:footnotePr>")
< at("<w:type")
);
}
/// `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, code: 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),
freeform: None,
effects: Default::default(),
extra: Default::default(),
}
};
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, code: 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(), move_name: None });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
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, code: 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(), move_name: None });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
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, code: false },
InlineObject::Field { instr: "DATE \\@ \"M/d/yyyy\"".into(), result: "1/2/2026".into(), locked: true, code: false },
InlineObject::Field { instr: "NUMPAGES".into(), result: String::new(), locked: false, code: 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),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
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,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
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);
}
/// A group of a picture, a shape and a text box (`wpg:wgp`) comes back as it was saved: its
/// size, placement and members' space, and each member's offset, size and content.
#[test]
fn groups_round_trip() {
use wordcraft_doc::para::GroupChild;
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("In the group", CharProps::default()))]);
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "A picture".into(), float: Float::default(), crop: [0.0; 4], ole: None };
let oval = InlineObject::Shape {
kind: ShapeKind::Ellipse,
w: 50.0,
h: 30.0,
fill: Some(Rgb(200, 0, 0)),
stroke: None,
stroke_width: 0.0,
float: Float::default(),
story: None,
freeform: None,
// A member's shape effects (#275) come back too.
effects: wordcraft_doc::effects::ShapeEffects {
shadow: Some(wordcraft_doc::effects::Shadow {
color: Rgb(0x20, 0x30, 0x40),
transparency: 60.0,
blur: 4.0,
distance: 3.0,
angle: 135.0,
rot_with_shape: false,
}),
glow: None,
soft_edge: Some(2.5),
},
extra: Default::default(),
};
let tb = InlineObject::Shape {
kind: ShapeKind::TextBox,
w: 100.0,
h: 40.0,
fill: Some(Rgb(255, 255, 255)),
stroke: Some(Rgb(0, 0, 0)),
stroke_width: 0.75,
float: Float::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
let float = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 20.0, y: 10.0, dist: 9.0, ..Default::default() };
let group = InlineObject::Group {
w: 240.0,
h: 120.0,
float: float.clone(),
ch_w: 200.0,
ch_h: 100.0,
children: vec![
GroupChild { x: 0.0, y: 0.0, obj: pic },
GroupChild { x: 150.0, y: 10.0, obj: oval.clone() },
GroupChild { x: 50.0, y: 60.0, obj: tb },
],
canvas: None,
};
let mut p = Paragraph::with_text("grouped ", CharProps::default());
let end = p.len();
p.insert_object(end, group, &CharProps::default()).unwrap();
d.body = vec![para_block(p)];
let r = rt(&d);
let got = paras(&r);
let InlineObject::Group { w, h, float: f, ch_w, ch_h, children, .. } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) };
assert_eq!((*w, *h, *ch_w, *ch_h), (240.0, 120.0, 200.0, 100.0));
assert_eq!(*f, float);
assert_eq!(children.iter().map(|c| (c.x, c.y)).collect::<Vec<_>>(), [(0.0, 0.0), (150.0, 10.0), (50.0, 60.0)]);
match &children[0].obj {
InlineObject::Image { media, w, h, alt, .. } => {
assert_eq!((*w, *h, alt.as_str()), (60.0, 40.0, "A picture"));
assert_eq!(r.media.get(media).map(|m| m.as_slice()), Some(tiny_png().as_slice()));
}
o => panic!("{o:?}"),
}
assert_eq!(children[1].obj, oval);
match &children[2].obj {
InlineObject::Shape { kind, w, h, story, .. } => {
assert_eq!((*kind, *w, *h), (ShapeKind::TextBox, 100.0, 40.0));
assert_eq!(part_text(&r, *story), "In the group");
}
o => panic!("{o:?}"),
}
}
/// A Drawing Canvas (`wpc:wpc`, #344) with a picture and a shape comes back as a canvas: its
/// frame, background and outline, and each member at its offset in the canvas, unscaled.
#[test]
fn drawing_canvas_round_trips() {
use wordcraft_doc::para::{CanvasStyle, GroupChild};
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "In the canvas".into(), float: Float::default(), crop: [0.0; 4], ole: None };
let star = InlineObject::Shape {
kind: ShapeKind::Star,
w: 72.0,
h: 72.0,
fill: Some(Rgb(0xE0, 0xA0, 0x20)),
stroke: Some(Rgb(0x40, 0x40, 0x40)),
stroke_width: 1.0,
float: Float::default(),
story: None,
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
let style = CanvasStyle { fill: Some(Rgb(0xF0, 0xF4, 0xFF)), stroke: Some(Rgb(0x20, 0x40, 0x80)), stroke_width: 1.5 };
let canvas = InlineObject::Group {
w: 432.0,
h: 216.0,
float: Float::default(),
ch_w: 432.0,
ch_h: 216.0,
children: vec![GroupChild { x: 12.0, y: 18.0, obj: pic }, GroupChild { x: 300.0, y: 100.0, obj: star.clone() }],
canvas: Some(style),
};
let mut p = Paragraph::with_text("drawn ", CharProps::default());
let end = p.len();
p.insert_object(end, canvas, &CharProps::default()).unwrap();
d.body = vec![para_block(p)];
let bytes = wordcraft_docx::write(&d).unwrap();
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("wordprocessingCanvas") && xml.contains("<wpc:wpc>") && xml.contains("<wpc:bg>") && xml.contains("<wpc:whole>"), "{xml}");
let r = wordcraft_docx::read(&bytes).unwrap();
let got = paras(&r);
let InlineObject::Group { w, h, ch_w, ch_h, children, canvas, .. } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) };
assert_eq!((*w, *h, *ch_w, *ch_h), (432.0, 216.0, 432.0, 216.0));
assert_eq!(*canvas, Some(style));
assert_eq!(children.iter().map(|c| (c.x, c.y)).collect::<Vec<_>>(), [(12.0, 18.0), (300.0, 100.0)]);
assert!(matches!(&children[0].obj, InlineObject::Image { w, h, alt, .. } if (*w, *h, alt.as_str()) == (60.0, 40.0, "In the canvas")));
assert_eq!(children[1].obj, star);
}
/// A turned and flipped Drawing Canvas (#344 with #332): the canvas schema has no transform, so
/// its turn rides in our `wpc:extLst` extension, after its members; members keep their own turn.
/// An unturned canvas writes no extension.
#[test]
fn rotated_drawing_canvas_round_trips() {
use wordcraft_doc::para::{CanvasStyle, GroupChild};
let member = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 60.0,
h: 40.0,
fill: Some(Rgb(1, 2, 3)),
stroke: None,
stroke_width: 0.0,
float: Float { rot: 15.0, ..Default::default() },
story: None,
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
let canvas = |float: Float| InlineObject::Group {
w: 200.0,
h: 100.0,
float,
ch_w: 200.0,
ch_h: 100.0,
children: vec![GroupChild { x: 10.0, y: 20.0, obj: member.clone() }],
canvas: Some(CanvasStyle { fill: Some(Rgb(0xF0, 0xF4, 0xFF)), stroke: None, stroke_width: 0.0 }),
};
let square = Float { wrap: Wrap::Square, ..Default::default() };
let mut p = Paragraph::with_text("c", CharProps::default());
p.insert_object(1, canvas(Float { rot: 30.0, flip_h: true, ..square.clone() }), &CharProps::default()).unwrap();
let end = p.len();
p.insert_object(end, canvas(square), &CharProps::default()).unwrap();
let bytes = wordcraft_docx::write(&doc_with(vec![p])).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#"<wpc:extLst><a:ext uri="urn:wordcraft:canvas-xfrm"><a:xfrm rot="1800000" flipH="1"/></a:ext></wpc:extLst></wpc:wpc>"#),
"{xml}"
);
assert_eq!(xml.matches("<wpc:extLst>").count(), 1, "{xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
let got = &paras(&r)[0].objects;
let spins: Vec<(f32, bool, bool)> = got.iter().map(|o| o.frame().unwrap().2).map(|f| (f.rot, f.flip_h, f.flip_v)).collect();
assert_eq!(spins, [(30.0, true, false), (0.0, false, false)]);
let InlineObject::Group { children, canvas, .. } = &got[0] else { panic!("{got:?}") };
assert!(canvas.is_some());
assert_eq!(children.len(), 1);
assert_eq!(children[0].obj.frame().unwrap().2.rot, 15.0);
}
#[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);
}
}
/// Tools › Templates and Add-ins: the attached template (an external relationship of the settings
/// part) and "Automatically update document styles" (`w:linkStyles`) round-trip, written in the
/// schema's order (after `w:mirrorMargins`, before `w:trackRevisions`).
#[test]
fn attached_template_and_link_styles_round_trip() {
let mut d = Document::new();
d.settings.attached_template = Some("file:///C:/Templates/Report & Co.dotx".into());
d.settings.link_styles = true;
d.settings.mirror_margins = true;
d.settings.track_changes = true;
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/settings.xml").unwrap(), &mut xml).unwrap();
let at = |tag: &str| xml.find(tag).unwrap_or_else(|| panic!("{tag} missing: {xml}"));
assert!(at("<w:mirrorMargins") < at("<w:attachedTemplate"));
assert!(at("<w:attachedTemplate") < at("<w:linkStyles"));
assert!(at("<w:linkStyles") < at("<w:trackRevisions"));
let mut rels = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/_rels/settings.xml.rels").unwrap(), &mut rels).unwrap();
assert!(rels.contains("relationships/attachedTemplate") && rels.contains("TargetMode=\"External\""), "{rels}");
let r = wordcraft_docx::read(&bytes).expect("read");
assert_eq!(r.settings.attached_template.as_deref(), Some("file:///C:/Templates/Report & Co.dotx"));
assert!(r.settings.link_styles);
// Detached: neither element nor relationship is written.
let plain = rt(&Document::new());
assert_eq!(plain.settings.attached_template, None);
assert!(!plain.settings.link_styles);
}
#[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;
// Hyphenation Options (#407): zone, words in capitals, consecutive hyphens.
d.settings.hyphenation_zone = 27.0;
d.settings.hyphenate_caps = false;
d.settings.consecutive_hyphen_limit = 2;
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.settings.grid_h = 5.5;
d.settings.grid_v = 18.0;
d.settings.math = Some(wordcraft_doc::math::MathProps {
brk_bin: wordcraft_doc::math::BrkBin::Repeat,
brk_bin_sub: wordcraft_doc::math::BrkBinSub::PlusMinus,
wrap_indent: 36.0,
wrap_right: false,
});
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(), move_name: None });
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], ole: None },
&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);
}
/// A Word XML Document (Flat OPC) carries the same package as a .docx: reading it back gives
/// what the .docx round trip gives, pictures included (#548).
#[test]
fn flat_opc_round_trip_equals_docx() {
let d = kitchen_sink();
let xml = wordcraft_docx::write_flat(&d).expect("write flat");
let text = std::str::from_utf8(&xml).expect("utf-8");
assert!(text.starts_with("<?xml") && text.contains("<?mso-application progid=\"Word.Document\"?>"));
assert!(text.contains("pkg:name=\"/word/document.xml\"") && text.contains("<pkg:binaryData>"));
assert_eq!(wordcraft_docx::sniff_xml(&xml), wordcraft_docx::XmlKind::FlatOpc);
let back = wordcraft_docx::read_xml(&xml).expect("read flat");
assert_eq!(back, rt(&d));
assert_eq!(back.media.len(), 1);
}
#[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],
ole: None,
},
&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),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
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");
}
/// Ink strokes and freeform shapes are written as DrawingML custom geometry (`a:custGeom`,
/// `a:moveTo`/`a:lnTo`) in floating drawings and read back with their points, pen, opacity and
/// drawing order.
#[test]
fn ink_and_freeforms_round_trip_as_custom_geometry() {
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool};
let shape = |fill, stroke_width, wrap, f: Freeform| InlineObject::Shape {
kind: ShapeKind::Freeform,
w: f.w,
h: f.h,
fill,
stroke: Some(Rgb(0xC0, 0, 0)),
stroke_width,
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 30.0, y: 12.0, ..Default::default() },
story: None,
freeform: Some(Arc::new(f)),
effects: Default::default(),
extra: Default::default(),
};
// The pen stroke was the seventh drawn: Ink Replay's order comes back too.
let pen = shape(
None,
2.0,
Wrap::InFrontOfText,
Freeform { order: Some(7), ..Freeform::ink(InkTool::Pen, 60.0, 20.0, vec![[1.0, 1.0], [30.0, 19.0], [59.0, 4.0]]) },
);
let marker = shape(None, 12.0, Wrap::BehindText, Freeform::ink(InkTool::Highlighter, 80.0, 12.0, vec![[6.0, 6.0], [74.0, 6.0]]));
let triangle = Freeform {
w: 40.0,
h: 40.0,
paths: vec![FreePath { pts: vec![[0.0, 40.0], [20.0, 0.0], [40.0, 40.0]], closed: true }],
..Default::default()
};
let tri = shape(Some(Rgb(0, 0x80, 0)), 1.0, Wrap::Square, triangle);
let mut p = Paragraph::with_text("Inked", CharProps::default());
for o in [pen.clone(), marker, tri.clone()] {
p.insert_object(0, o, &CharProps::default()).unwrap();
}
let d = doc_with(vec![p]);
let bytes = wordcraft_docx::write(&d).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();
assert_eq!(xml.matches("<a:custGeom>").count(), 3, "{xml}");
assert!(xml.contains(r#"<a:path w="762000" h="254000" fill="none"><a:moveTo><a:pt x="12700" y="12700"/></a:moveTo><a:lnTo>"#), "{xml}");
assert!(xml.contains(r#"<a:alpha val="50000"/>"#) && xml.contains(r#"cap="rnd""#), "{xml}");
let r = rt(&d);
let objs = &paras(&r)[0].objects;
assert_eq!(objs.len(), 3);
// Written in reverse (each inserted at the start): the triangle, the highlighter, the pen.
assert_eq!(objs[0], tri, "a filled closed freeform comes back as it was");
let InlineObject::Shape { freeform: Some(m), float, stroke_width, .. } = &objs[1] else { panic!("{:?}", objs[1]) };
assert_eq!((m.ink, m.alpha, float.wrap, *stroke_width), (Some(InkTool::Highlighter), 0.5, Wrap::BehindText, 12.0));
assert_eq!(objs[2], pen, "the pen stroke comes back point for point");
}
/// Shape effects (#275): `a:effectLst` with an outer shadow, a glow and soft edges round-trips,
/// and the effect extent leaves room for them.
#[test]
fn shape_effects_round_trip() {
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
let effects = ShapeEffects {
shadow: Some(Shadow { color: Rgb(0x20, 0x30, 0x40), transparency: 60.0, blur: 4.0, distance: 3.0, angle: 135.0, rot_with_shape: false }),
glow: Some(Glow { color: Rgb(0xC0, 0x50, 0x10), size: 8.0, transparency: 40.0 }),
soft_edge: Some(2.5),
};
let shape = InlineObject::Shape {
kind: ShapeKind::RoundedRectangle,
w: 100.0,
h: 50.0,
fill: Some(Rgb(0x15, 0x60, 0x82)),
stroke: None,
stroke_width: 0.0,
float: Float::default(),
story: None,
freeform: None,
effects,
extra: Default::default(),
};
let mut p = Paragraph::with_text("x", CharProps::default());
p.insert_object(1, shape, &CharProps::default()).unwrap();
let bytes = wordcraft_docx::write(&doc_with(vec![p])).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#"<a:outerShdw blurRad="50800" dist="38100" dir="8100000" algn="tr" rotWithShape="0"><a:srgbClr val="203040"><a:alpha val="40000"/>"#
),
"{xml}"
);
assert!(xml.contains(r#"<a:glow rad="101600"><a:srgbClr val="C05010"><a:alpha val="60000"/>"#), "{xml}");
assert!(xml.contains(r#"<a:softEdge rad="31750"/>"#), "{xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
match paras(&r)[0].objects.first() {
Some(InlineObject::Shape { effects: got, float, .. }) => {
assert_eq!(*got, effects);
assert_eq!(float.effect_extent(), effects.extent());
}
o => panic!("{o:?}"),
}
}
#[test]
fn equation_manual_breaks_round_trip() {
use wordcraft_doc::math::{MNode, MRun, Math};
let nodes = vec![
MNode::Run(MRun::new("a=b")),
MNode::Run(MRun { brk: Some(1), ..MRun::new("+c") }),
MNode::Run(MRun { brk: Some(0), ..MRun::new("+d") }),
];
let mut d = Document::new();
let math = Math { nodes: nodes.clone(), ..Default::default() };
let mut p = Paragraph::with_text("", CharProps::default());
p.insert_object(0, InlineObject::Equation { linear: "a=b+c+d".into(), display: true, math }, &CharProps::default()).unwrap();
d.body = vec![para_block(p)];
let r = rt(&d);
let got = paras(&r).iter().flat_map(|p| p.objects.iter()).find_map(|o| match o {
InlineObject::Equation { math, .. } => Some(math.nodes.clone()),
_ => None,
});
assert_eq!(got, Some(nodes));
// Word's own settings round-trip; a document without them gets none.
assert_eq!(r.settings.math, None);
}
/// #332: rotation (`a:xfrm/@rot`, 60000ths of a degree) and flips of pictures, shapes, text boxes
/// and groups survive save and load; the effect extent written covers the rotated bounds, and
/// reading takes that overhang back out of the effects room.
#[test]
fn rotation_and_flips_round_trip() {
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
let pic = InlineObject::Image {
media: key,
w: 100.0,
h: 50.0,
alt: String::new(),
float: Float { rot: 30.0, effect: [6.0; 4], ..Default::default() },
crop: [0.0; 4],
ole: None,
};
let shape = |kind, float| InlineObject::Shape {
kind,
w: 80.0,
h: 40.0,
fill: Some(Rgb(1, 2, 3)),
stroke: None,
stroke_width: 0.0,
float,
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
let square = Float { wrap: Wrap::Square, ..Default::default() };
let tri = shape(ShapeKind::Triangle, Float { rot: 90.0, flip_h: true, ..square.clone() });
let flipped = shape(ShapeKind::Rectangle, Float { rot: 315.5, flip_v: true, ..square.clone() });
let group = InlineObject::Group {
w: 120.0,
h: 60.0,
float: Float { rot: 45.0, ..square },
ch_w: 120.0,
ch_h: 60.0,
children: vec![wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Ellipse, Float { rot: 10.0, ..Default::default() }) }],
canvas: None,
};
let mut p = Paragraph::with_text("turned ", CharProps::default());
for o in [pic, tri, flipped, group] {
let end = p.len();
p.insert_object(end, o, &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/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"<a:xfrm rot="1800000">"#), "{xml}");
assert!(xml.contains(r#"<a:xfrm rot="5400000" flipH="1">"#), "{xml}");
assert!(xml.contains(r#"<a:xfrm rot="18930000" flipV="1">"#), "{xml}");
// The 100 × 50 picture turned 30° covers 111.6 × 93.3: 5.8 and 21.7 pt more on each side,
// plus its 6 pt of effects room.
assert!(xml.contains(r#"<wp:effectExtent l="149876" t="351163" r="149876" b="351163"/>"#), "{xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
let got = &paras(&r)[0].objects;
let spins: Vec<(f32, bool, bool)> = got.iter().map(|o| o.frame().unwrap().2).map(|f| (f.rot, f.flip_h, f.flip_v)).collect();
assert_eq!(spins, [(30.0, false, false), (90.0, true, false), (315.5, false, true), (45.0, false, false)]);
let effect = got[0].frame().unwrap().2.effect;
assert!(effect.iter().all(|e| (e - 6.0).abs() < 0.01), "{effect:?}");
assert!(got[1].frame().unwrap().2.effect.iter().all(|e| e.abs() < 0.01), "no effects room appears");
let InlineObject::Group { children, .. } = &got[3] else { panic!("{got:?}") };
assert_eq!(children[0].obj.frame().unwrap().2.rot, 10.0);
}
/// #332: a group's own turn and flips are on its `wpg:grpSpPr/a:xfrm` and its members keep
/// theirs inside it; whether a member's shadow turns with it (`rotWithShape`) survives too.
#[test]
fn rotated_group_and_shadow_rotation_round_trip() {
use wordcraft_doc::effects::{Shadow, ShapeEffects};
let member = |rot_with_shape: bool| InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 60.0,
h: 40.0,
fill: Some(Rgb(1, 2, 3)),
stroke: None,
stroke_width: 0.0,
float: Float { rot: 20.0, ..Default::default() },
story: None,
freeform: None,
effects: ShapeEffects { shadow: Some(Shadow { rot_with_shape, ..Default::default() }), ..Default::default() },
extra: Default::default(),
};
let group = InlineObject::Group {
w: 120.0,
h: 40.0,
float: Float { wrap: Wrap::Square, rot: 135.0, flip_h: true, ..Default::default() },
ch_w: 120.0,
ch_h: 40.0,
children: vec![
wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj: member(true) },
wordcraft_doc::para::GroupChild { x: 60.0, y: 0.0, obj: member(false) },
],
canvas: None,
};
let mut p = Paragraph::with_text("g", CharProps::default());
p.insert_object(1, group, &CharProps::default()).unwrap();
let bytes = wordcraft_docx::write(&doc_with(vec![p])).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#"<wpg:grpSpPr><a:xfrm rot="8100000" flipH="1">"#), "{xml}");
assert!(xml.contains(r#"rotWithShape="1""#) && xml.contains(r#"rotWithShape="0""#), "{xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
let Some(InlineObject::Group { float, children, .. }) = paras(&r)[0].objects.first() else { panic!("{:?}", paras(&r)[0].objects) };
assert_eq!((float.rot, float.flip_h, float.flip_v), (135.0, true, false));
let got: Vec<(f32, Option<bool>)> = children
.iter()
.map(|c| match &c.obj {
InlineObject::Shape { float, effects, .. } => (float.rot, effects.shadow.map(|s| s.rot_with_shape)),
o => panic!("{o:?}"),
})
.collect();
assert_eq!(got, [(20.0, Some(true)), (20.0, Some(false))]);
}
#[test]
fn wrap_polygons_round_trip() {
use wordcraft_doc::wrap::{WrapPolygon, shape_outline};
let mut d = Document::new();
let shape = |kind, float| InlineObject::Shape {
kind,
w: 80.0,
h: 40.0,
fill: Some(Rgb(1, 2, 3)),
stroke: None,
stroke_width: 0.0,
float,
story: None,
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
// A hand-edited polygon, including points outside the frame.
let poly = WrapPolygon::new(true, [(-300, 0), (21_600, 1200), (18_000, 21_900), (0, 21_600)]).map(Arc::new);
let edited = Float { wrap: Wrap::Tight, v_rel: Anchor::Paragraph, wrap_polygon: poly.clone(), ..Default::default() };
// None: Through wrapping needs one, so the ellipse's own outline is written.
let derived = Float { wrap: Wrap::Through, ..Default::default() };
let mut p = Paragraph::with_text("wrapped ", CharProps::default());
for o in [shape(ShapeKind::Rectangle, edited.clone()), shape(ShapeKind::Ellipse, derived)] {
let end = p.len();
p.insert_object(end, o, &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/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"<wp:wrapPolygon edited="1"><wp:start x="-300" y="0"/><wp:lineTo x="21600" y="1200"/>"#), "{xml}");
assert!(xml.contains(r#"<wp:lineTo x="-300" y="0"/></wp:wrapPolygon>"#), "closed: {xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
let got = paras(&r)[0];
let floats: Vec<Float> = got.objects.iter().filter_map(|o| o.frame().map(|(_, _, f)| f.clone())).collect();
assert_eq!(floats[0], edited);
let ellipse = WrapPolygon::from_unit(false, &shape_outline(ShapeKind::Ellipse, 80.0, 40.0).unwrap());
assert_eq!(floats[1].wrap, Wrap::Through);
assert_eq!(floats[1].wrap_polygon.as_deref(), ellipse.as_ref());
}
/// Enclosed characters (#297) are written as Word stores them — an `eq \o\ac(○,字)` field with no
/// result, the symbol and the text in runs of their own sizes — and read back with their style,
/// whether the field's size is set on the run or comes from the styles.
#[test]
fn enclosed_characters_round_trip_in_words_structure() {
use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure};
let mut p = Paragraph::new();
let cases = [
(EncloseShape::Circle, EncloseStyle::Shrink, Some(11.0)),
(EncloseShape::Triangle, EncloseStyle::Enlarge, Some(11.0)),
(EncloseShape::Square, EncloseStyle::Shrink, None),
(EncloseShape::Diamond, EncloseStyle::Enlarge, None),
];
for (shape, style, size) in cases {
let e = Enclosure { shape, style, text: "字".into() };
let off = p.len();
p.insert_object(
off,
InlineObject::Field { instr: e.instr(), result: "字".into(), locked: false, code: false },
&CharProps { size, ..Default::default() },
)
.unwrap();
}
let doc = doc_with(vec![p]);
let bytes = wordcraft_docx::write(&doc).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"<w:instrText xml:space="preserve"> eq \o\ac(○</w:instrText>"#), "{xml}");
assert!(!xml.contains("separate") && !xml.contains("enlarge"), "{xml}");
// Shrink text at 11 pt: the character at 7.5 pt (half-points 15), raised.
assert!(xml.contains(r#"<w:sz w:val="15"/>"#), "{xml}");
let back = wordcraft_docx::read(&bytes).unwrap();
let got: Vec<Enclosure> = paras(&back)[0]
.objects
.iter()
.filter_map(|o| if let InlineObject::Field { instr, result, .. } = o { Enclosure::parse(instr).filter(|_| result == "字") } else { None })
.collect();
let want: Vec<Enclosure> = cases.iter().map(|(shape, style, _)| Enclosure { shape: *shape, style: *style, text: "字".into() }).collect();
assert_eq!(got, want);
}
#[test]
fn ruby_phonetic_guide_round_trips_as_w_ruby() {
use wordcraft_doc::para::RubyAlign;
let mut p = Paragraph::with_text("Read daily.", CharProps::default());
let bold = CharProps { bold: Some(true), ..Default::default() };
let ruby = InlineObject::Ruby {
base: "漢字".into(),
ruby: "かんじ".into(),
align: RubyAlign::DistributeSpace,
size: 5.5,
raise: 10.0,
base_size: 11.0,
lang: "ja-JP".into(),
props: Box::new(CharProps { font: Some("Noto Sans CJK JP".into()), ..Default::default() }),
};
p.insert_object(5, ruby.clone(), &bold).unwrap();
let d = doc_with(vec![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/document.xml").unwrap(), &mut xml).unwrap();
for part in [
r#"<w:rubyAlign w:val="distributeSpace"/><w:hps w:val="11"/><w:hpsRaise w:val="20"/><w:hpsBaseText w:val="22"/><w:lid w:val="ja-JP"/>"#,
"<w:rt><w:r>",
">かんじ</w:t>",
"<w:rubyBase><w:r><w:rPr><w:b/>",
">漢字</w:t>",
] {
assert!(xml.contains(part), "missing {part}: {xml}");
}
let r = wordcraft_docx::read(&bytes).expect("read");
let q = paras(&r)[0];
assert_eq!(q.plain_text(), "Read 漢字 daily.");
assert_eq!(q.objects, vec![ruby]);
assert_eq!(q.props_of_char(5).bold, Some(true));
}
/// #354: the sides text wraps on (`@wrapText`) are read and written for Square, Tight and
/// Through; both sides is the default.
#[test]
fn wrap_text_sides_round_trip() {
use wordcraft_doc::para::WrapText;
let mut d = Document::new();
let shape = |float| InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 80.0,
h: 40.0,
fill: Some(Rgb(1, 2, 3)),
stroke: None,
stroke_width: 0.0,
float,
story: None,
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
let sides =
[(Wrap::Square, WrapText::Left), (Wrap::Tight, WrapText::Right), (Wrap::Through, WrapText::Largest), (Wrap::Square, WrapText::BothSides)];
let mut p = Paragraph::with_text("wrapped ", CharProps::default());
for (wrap, side) in sides {
let end = p.len();
p.insert_object(end, shape(Float { wrap, wrap_text: side, v_rel: Anchor::Paragraph, ..Default::default() }), &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/document.xml").unwrap(), &mut xml).unwrap();
for tag in [
r#"<wp:wrapSquare wrapText="left"/>"#,
r#"<wp:wrapTight wrapText="right">"#,
r#"<wp:wrapThrough wrapText="largest">"#,
r#"<wp:wrapSquare wrapText="bothSides"/>"#,
] {
assert!(xml.contains(tag), "{tag}: {xml}");
}
let r = wordcraft_docx::read(&bytes).expect("read");
let got: Vec<_> = paras(&r)[0].objects.iter().filter_map(|o| o.frame().map(|(_, _, f)| (f.wrap, f.wrap_text))).collect();
assert_eq!(got, sides);
// A value outside the schema is no side (read as both sides).
assert_eq!(WrapText::from_ooxml("middle"), None);
}
#[test]
fn alt_text_title_and_decorative_round_trip_for_every_object() {
use wordcraft_doc::para::AltText;
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
let pic = InlineObject::Image {
media: key,
w: 100.0,
h: 50.0,
alt: "A kiln at dusk".into(),
float: Float { alt: AltText { title: "Kiln".into(), ..Default::default() }, ..Default::default() },
crop: [0.0; 4],
ole: None,
};
let shape = |alt: AltText| InlineObject::Shape {
kind: ShapeKind::Ellipse,
w: 80.0,
h: 40.0,
fill: Some(Rgb(1, 2, 3)),
stroke: None,
stroke_width: 0.0,
float: Float { alt, ..Default::default() },
story: None,
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
let described = shape(AltText { text: "Process flow & \"steps\"".into(), ..Default::default() });
let decorative = shape(AltText { decorative: true, ..Default::default() });
let group = InlineObject::Group {
w: 120.0,
h: 60.0,
float: Float { alt: AltText { text: "Two circles".into(), ..Default::default() }, ..Default::default() },
ch_w: 120.0,
ch_h: 60.0,
children: vec![wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj: shape(AltText { text: "Left circle".into(), ..Default::default() }) }],
canvas: None,
};
let mut p = Paragraph::with_text("objects ", CharProps::default());
for o in [pic, described, decorative, group] {
let end = p.len();
p.insert_object(end, o, &CharProps::default()).unwrap();
}
let mut t = Table::new(2, 2, 300.0);
t.props.caption = Some("Prices".into());
t.props.description = Some("Prices by year".into());
d.body = vec![para_block(p), Arc::new(Block::Table(t))];
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#"{C183D7F6-B498-43B3-948B-1728B52AA6E4}"><adec:decorative xmlns:adec="http://schemas.microsoft.com/office/drawing/2017/decorative" val="1"/>"#), "{xml}");
assert!(
xml.contains(r#"<w:tblLook "#) && xml.contains(r#"<w:tblCaption w:val="Prices"/><w:tblDescription w:val="Prices by year"/></w:tblPr>"#),
"{xml}"
);
let r = wordcraft_docx::read(&bytes).expect("read");
let objs = &paras(&r)[0].objects;
let alts: Vec<_> =
objs.iter().map(|o| (o.alt_text().to_string(), o.frame().map(|f| f.2.alt.title.clone()).unwrap_or_default(), o.is_decorative())).collect();
assert_eq!(
alts,
[
("A kiln at dusk".into(), "Kiln".into(), false),
("Process flow & \"steps\"".into(), String::new(), false),
(String::new(), String::new(), true),
("Two circles".into(), String::new(), false),
]
);
let InlineObject::Group { children, .. } = &objs[3] else { panic!("{objs:?}") };
assert_eq!(children[0].obj.alt_text(), "Left circle");
let Some(Block::Table(t)) = r.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!() };
assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), Some("Prices by year")));
}
/// WordArt's warp (`a:prstTxWarp` with its adjust value) and connectors glued to shapes
/// (`a:stCxn`/`a:endCxn`, with arrowheads) come back as saved; a shape no connector is glued to
/// loses its drawing id, and hostile values are reduced.
#[test]
fn warps_and_glued_connectors_round_trip() {
use wordcraft_doc::connector::{ConnEnd, ShapeExtra};
use wordcraft_doc::wordart::TextWarp;
let mut d = Document::new();
let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Bent", CharProps::default()))]);
let float = |x: f32| Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, y: 10.0, ..Default::default() };
let shape = |kind, story, extra| InlineObject::Shape {
kind,
w: 72.0,
h: 36.0,
fill: None,
stroke: Some(Rgb(0, 0, 0)),
stroke_width: 1.0,
float: float(0.0),
story,
freeform: None,
effects: Default::default(),
extra,
};
let mut warp = TextWarp::new("textArchUp").unwrap();
warp.set_adj("adj", 9_000_000);
let art = shape(ShapeKind::TextBox, Some(story), ShapeExtra { id: 5, warp: Some(warp), ..Default::default() });
let target = shape(ShapeKind::Ellipse, None, ShapeExtra { id: 7, ..Default::default() });
let lonely = shape(ShapeKind::Rectangle, None, ShapeExtra { id: 9, ..Default::default() });
let link = ShapeExtra { start: Some(ConnEnd { id: 5, site: 3 }), end: Some(ConnEnd { id: 7, site: 2 }), arrow_end: true, ..Default::default() };
let mut conn = shape(ShapeKind::ElbowConnector, None, link);
if let InlineObject::Shape { float: f, .. } = &mut conn {
*f = Float { flip_v: true, ..float(80.0) };
}
let mut p = Paragraph::with_text("", CharProps::default());
for o in [art.clone(), conn.clone(), target.clone(), lonely] {
let end = p.len();
p.insert_object(end, o, &CharProps::default()).unwrap();
}
d.body = vec![para_block(p)];
let r = rt(&d);
let got = &paras(&r)[0].objects;
// The glued-to shapes keep their ids, the connector its ends, arrowhead and flip.
let extra = |o: &InlineObject| match o {
InlineObject::Shape { extra, .. } => extra.clone(),
_ => panic!("{o:?}"),
};
assert_eq!(extra(&got[0]), extra(&art));
assert_eq!(extra(&got[1]), extra(&conn));
assert!(matches!(&got[1], InlineObject::Shape { kind: ShapeKind::ElbowConnector, float, .. } if float.flip_v));
assert_eq!(extra(&got[2]), extra(&target));
assert_eq!(extra(&got[3]).id, 0, "nothing is glued to it");
// Hostile: an unknown warp, a huge adjust value, a glue to a missing shape.
let xml = wordcraft_docx::write(&r).unwrap();
let mut pkg = zip::ZipArchive::new(std::io::Cursor::new(xml)).unwrap();
let mut doc_xml = String::new();
std::io::Read::read_to_string(&mut pkg.by_name("word/document.xml").unwrap(), &mut doc_xml).unwrap();
let doc_xml = doc_xml
.replace("prst=\"textArchUp\"", "prst=\"textNotAWarp\"")
.replace("a:stCxn id=\"5\" idx=\"3\"", "a:stCxn id=\"404\" idx=\"99999999999\"");
let bytes = replace_part(&wordcraft_docx::write(&r).unwrap(), "word/document.xml", doc_xml.as_bytes());
let h = wordcraft_docx::read(&bytes).unwrap();
let got = &paras(&h)[0].objects;
assert_eq!(extra(&got[0]).warp, None);
assert_eq!(extra(&got[1]).start, Some(ConnEnd { id: 404, site: wordcraft_doc::connector::MAX_SITE }));
}
/// `zip` with part `name` replaced by `data`.
fn replace_part(zip_bytes: &[u8], name: &str, data: &[u8]) -> Vec<u8> {
let mut src = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap();
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..src.len() {
let mut f = src.by_index(i).unwrap();
let n = f.name().to_string();
out.start_file(n.as_str(), zip::write::SimpleFileOptions::default()).unwrap();
if n == name {
std::io::Write::write_all(&mut out, data).unwrap();
} else {
std::io::copy(&mut f, &mut out).unwrap();
}
}
out.finish().unwrap().into_inner()
}