use super::{
DocOutcome, Relationship, SkippedImages, append_entity, attr, ensure_out_dir, read_zip_entry,
relationships, resolve_part, scan_elements, unopened_parts, unreadable, write_media_parts,
};
use crate::reader_output::{
TEXT_BOX, WORD_COMMENT, WORD_ENDNOTE, WORD_FOOTER, WORD_FOOTNOTE, WORD_HEADER, labeled_block,
lines_of, text_lines,
};
use quick_xml::Reader;
use quick_xml::events::{BytesStart, Event};
use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use std::io::{Cursor, Read, Seek};
use std::path::Path;
use std::rc::Rc;
use zip::ZipArchive;
const MAX_ELEMENT_DEPTH: usize = 128;
const MAX_TABLE_COLUMNS: usize = 256;
const DOCX_BODY_PART: &str = "word/document.xml";
const DOCX_MEDIA_PREFIX: &str = "word/media/";
const DOCX_CHARTS_PREFIX: &str = "word/charts/";
const DOCX_DIAGRAMS_PREFIX: &str = "word/diagrams/";
const DOCX_EMBEDDINGS_PREFIX: &str = "word/embeddings/";
const DOCX_RELS_PART: &str = "word/_rels/document.xml.rels";
const DOCX_BASE: &str = "word/";
const MAX_FIELD_INSTRUCTION_CHARS: usize = 80;
type Xml<'a> = Reader<&'a [u8]>;
struct Fault;
struct Elem {
name: Vec<u8>,
attrs: Vec<(Vec<u8>, String)>,
}
impl Elem {
fn of(event: &BytesStart<'_>) -> Self {
Self {
name: event.local_name().as_ref().to_vec(),
attrs: event
.attributes()
.with_checks(false)
.flatten()
.map(|attribute| {
(
attribute.key.local_name().as_ref().to_vec(),
attribute
.unescape_value()
.map(std::borrow::Cow::into_owned)
.unwrap_or_default(),
)
})
.collect(),
}
}
fn attr(&self, name: &str) -> Option<&str> {
self.attrs
.iter()
.find(|(key, _)| key.as_slice() == name.as_bytes())
.map(|(_, value)| value.as_str())
}
}
enum Ev {
Start(Elem),
Empty(Elem),
Text(String),
End,
Eof,
Skip,
Fault,
}
fn next_event(reader: &mut Xml<'_>, buffer: &mut Vec<u8>) -> Ev {
match reader.read_event_into(buffer) {
Ok(Event::Start(event)) => Ev::Start(Elem::of(&event)),
Ok(Event::Empty(event)) => Ev::Empty(Elem::of(&event)),
Ok(Event::End(_)) => Ev::End,
Ok(Event::Text(event)) => match event.xml10_content() {
Ok(text) => Ev::Text(text.into_owned()),
Err(_) => Ev::Skip,
},
Ok(Event::GeneralRef(reference)) => {
let mut text = String::new();
append_entity(&mut text, &reference);
Ev::Text(text)
}
Ok(Event::Eof) => Ev::Eof,
Ok(_) => Ev::Skip,
Err(_) => Ev::Fault,
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Ctx {
Flow,
ParagraphProps,
MarkProps,
RunProps,
TableProps,
RowProps,
CellProps,
SectionProps,
Ignored,
}
struct Word {
rel_targets: HashMap<String, String>,
footnotes: Notes,
endnotes: Notes,
comments: Notes,
buffers: Vec<String>,
paras: Vec<Vec<&'static str>>,
tables: Vec<Table>,
cells: Vec<Cell>,
fields: Vec<Field>,
comment_ranges: HashSet<String>,
run_revised: bool,
headers: PartRefs,
footers: PartRefs,
collect_sections: bool,
text_boxes: usize,
tables_opened: usize,
tables_empty: usize,
counting: bool,
revisions: Revisions,
}
impl Word {
fn new<R: Read + Seek>(rels: &[Relationship], archive: &mut ZipArchive<R>) -> Self {
let footnotes_part = notes_part(rels, NoteKind::Footnote);
let endnotes_part = notes_part(rels, NoteKind::Endnote);
let comments_part = notes_part(rels, NoteKind::Comment);
Self {
rel_targets: super::relationship_targets(rels),
footnotes: Notes::load(NoteKind::Footnote, &footnotes_part, archive),
endnotes: Notes::load(NoteKind::Endnote, &endnotes_part, archive),
comments: Notes::load(NoteKind::Comment, &comments_part, archive),
buffers: Vec::new(),
paras: Vec::new(),
tables: Vec::new(),
cells: Vec::new(),
fields: Vec::new(),
comment_ranges: HashSet::new(),
run_revised: false,
headers: PartRefs::default(),
footers: PartRefs::default(),
collect_sections: false,
text_boxes: 0,
tables_opened: 0,
tables_empty: 0,
counting: true,
revisions: Revisions::default(),
}
}
fn notes(&self, kind: NoteKind) -> &Notes {
match kind {
NoteKind::Footnote => &self.footnotes,
NoteKind::Endnote => &self.endnotes,
NoteKind::Comment => &self.comments,
}
}
fn notes_mut(&mut self, kind: NoteKind) -> &mut Notes {
match kind {
NoteKind::Footnote => &mut self.footnotes,
NoteKind::Endnote => &mut self.endnotes,
NoteKind::Comment => &mut self.comments,
}
}
fn rel(&self, id: &str) -> Option<&str> {
self.rel_targets.get(id).map(String::as_str)
}
fn push_str(&mut self, s: &str) {
if let Some(sink) = self.buffers.last_mut() {
sink.push_str(s);
}
}
fn end_line(&mut self) {
if let Some(sink) = self.buffers.last_mut()
&& !sink.is_empty()
&& !sink.ends_with('\n')
{
sink.push('\n');
}
}
fn block(&mut self, text: &str) {
self.end_line();
self.push_str(text);
self.end_line();
}
fn push_text(&mut self, text: &str) {
if let Some(field) = self.fields.last_mut()
&& field.pending
{
field.held.push_str(text);
return;
}
self.push_str(text);
}
fn record(&mut self, elem: &Elem) {
if self.counting {
self.revisions.record(elem);
}
}
fn body(&mut self, xml: &[u8]) -> Option<String> {
self.read_part(xml, true).ok()
}
fn read_part(&mut self, xml: &[u8], collect_sections: bool) -> Result<String, Fault> {
self.reset_part();
self.collect_sections = collect_sections;
let mut reader = Reader::from_reader(xml);
let result = self.render_block(&mut reader, 0);
self.collect_sections = false;
result
}
fn reset_part(&mut self) {
self.paras.clear();
self.tables.clear();
self.cells.clear();
self.fields.clear();
self.comment_ranges.clear();
self.text_boxes = 0;
}
fn content(&mut self, reader: &mut Xml<'_>, level: usize, ctx: Ctx) -> Result<(), Fault> {
if level >= MAX_ELEMENT_DEPTH {
return Err(Fault);
}
let mut buffer = Vec::new();
loop {
match next_event(reader, &mut buffer) {
Ev::Start(elem) => {
self.record(&elem);
self.element(reader, level + 1, ctx, &elem, false)?;
}
Ev::Empty(elem) => {
self.record(&elem);
self.element(reader, level + 1, ctx, &elem, true)?;
}
Ev::End | Ev::Eof => return Ok(()),
Ev::Fault => return Err(Fault),
Ev::Text(_) | Ev::Skip => {}
}
buffer.clear();
}
}
fn element(
&mut self,
reader: &mut Xml<'_>,
level: usize,
ctx: Ctx,
elem: &Elem,
empty: bool,
) -> Result<(), Fault> {
if is_property_change(&elem.name) {
self.change_marker(ctx, &elem.name);
return self.ignored(reader, level, empty);
}
match ctx {
Ctx::Ignored | Ctx::RunProps | Ctx::TableProps => self.ignored(reader, level, empty),
Ctx::Flow => self.body_element(reader, level, elem, empty),
Ctx::ParagraphProps => self.paragraph_props(reader, level, elem, empty),
Ctx::MarkProps => self.mark_props(reader, level, elem, empty),
Ctx::RowProps => self.row_props(reader, level, elem, empty),
Ctx::CellProps => self.cell_props(reader, level, elem, empty),
Ctx::SectionProps => self.section_props(reader, level, elem, empty),
}
}
fn ignored(&mut self, reader: &mut Xml<'_>, level: usize, empty: bool) -> Result<(), Fault> {
if empty {
Ok(())
} else {
self.content(reader, level + 1, Ctx::Ignored)
}
}
fn discarded(&mut self, reader: &mut Xml<'_>, level: usize, empty: bool) -> Result<(), Fault> {
let counting = self.counting;
self.counting = false;
let result = self.ignored(reader, level, empty);
self.counting = counting;
result
}
fn flow(&mut self, reader: &mut Xml<'_>, level: usize, empty: bool) -> Result<(), Fault> {
if empty {
Ok(())
} else {
self.content(reader, level + 1, Ctx::Flow)
}
}
fn paragraph(&mut self, reader: &mut Xml<'_>, level: usize, empty: bool) -> Result<(), Fault> {
if empty {
self.end_line();
return Ok(());
}
self.paras.push(Vec::new());
let result = self.content(reader, level + 1, Ctx::Flow);
for mark in self.paras.pop().unwrap_or_default() {
self.push_str(mark);
}
self.end_line();
result
}
fn run_text(&mut self, reader: &mut Xml<'_>, level: usize) -> Result<(), Fault> {
let mut buffer = Vec::new();
loop {
match next_event(reader, &mut buffer) {
Ev::Text(text) => self.push_text(&text),
Ev::Start(elem) => {
self.record(&elem);
self.ignored(reader, level, false)?;
}
Ev::Empty(elem) => self.record(&elem),
Ev::End | Ev::Eof => return Ok(()),
Ev::Fault => return Err(Fault),
Ev::Skip => {}
}
buffer.clear();
}
}
fn body_element(
&mut self,
reader: &mut Xml<'_>,
level: usize,
elem: &Elem,
empty: bool,
) -> Result<(), Fault> {
match elem.name.as_slice() {
b"p" => self.paragraph(reader, level, empty),
b"t" | b"delText" => {
if empty {
Ok(())
} else {
self.run_text(reader, level)
}
}
b"tab" | b"br" | b"cr" | b"noBreakHyphen" | b"softHyphen" | b"sym" => {
self.leaf_text(elem);
self.ignored(reader, level, empty)
}
b"tbl" => self.table(reader, level, empty),
b"tr" if !self.tables.is_empty() => self.row(reader, level, empty),
b"tc" if !self.tables.is_empty() => self.cell(reader, level, empty),
b"pPr" => self.props(reader, level, Ctx::ParagraphProps, empty),
b"rPr" => self.run_props(reader, level, empty),
b"tblPr" => self.props(reader, level, Ctx::TableProps, empty),
b"tblGrid" => self.table_grid(reader, level, empty),
b"trPr" | b"tblPrEx" => self.props(reader, level, Ctx::RowProps, empty),
b"tcPr" => self.props(reader, level, Ctx::CellProps, empty),
b"sectPr" => self.props(reader, level, Ctx::SectionProps, empty),
b"ins" | b"del" | b"moveTo" | b"moveFrom" => self.revision(reader, level, elem, empty),
b"footnoteReference" => {
self.note_reference(elem, NoteKind::Footnote);
self.ignored(reader, level, empty)
}
b"endnoteReference" => {
self.note_reference(elem, NoteKind::Endnote);
self.ignored(reader, level, empty)
}
b"commentRangeStart" | b"commentRangeEnd" | b"commentReference" => {
self.comment_marker(elem);
self.ignored(reader, level, empty)
}
b"fldSimple" => self.simple_field(reader, level, elem, empty),
b"fldChar" => {
self.field_char(elem);
self.ignored(reader, level, empty)
}
b"instrText" => self.instr_text(reader, level, empty),
b"delInstrText" | b"numberingChange" | b"sdtPr" | b"sdtEndPr" => {
self.ignored(reader, level, empty)
}
b"txbxContent" => self.text_box(reader, level, empty),
b"AlternateContent" => self.alternate_content(reader, level, empty),
_ => self.flow(reader, level, empty),
}
}
fn leaf_text(&mut self, elem: &Elem) {
match elem.name.as_slice() {
b"tab" => self.push_str("\t"),
b"br" | b"cr" => self.end_line(),
b"noBreakHyphen" => self.push_str("-"),
_ => {}
}
}
fn props(
&mut self,
reader: &mut Xml<'_>,
level: usize,
ctx: Ctx,
empty: bool,
) -> Result<(), Fault> {
if empty {
Ok(())
} else {
self.content(reader, level + 1, ctx)
}
}
fn paragraph_props(
&mut self,
reader: &mut Xml<'_>,
level: usize,
elem: &Elem,
empty: bool,
) -> Result<(), Fault> {
match elem.name.as_slice() {
b"rPr" => self.props(reader, level, Ctx::MarkProps, empty),
b"sectPr" => self.props(reader, level, Ctx::SectionProps, empty),
_ => self.ignored(reader, level, empty),
}
}
fn mark_props(
&mut self,
reader: &mut Xml<'_>,
level: usize,
elem: &Elem,
empty: bool,
) -> Result<(), Fault> {
match elem.name.as_slice() {
b"ins" => self.para_mark("[ins ¶]"),
b"del" => self.para_mark("[del ¶]"),
b"moveTo" => self.para_mark("[moved-here ¶]"),
b"moveFrom" => self.para_mark("[moved-away ¶]"),
_ => {}
}
self.ignored(reader, level, empty)
}
fn run_props(&mut self, reader: &mut Xml<'_>, level: usize, empty: bool) -> Result<(), Fault> {
self.run_revised = false;
let result = self.props(reader, level, Ctx::RunProps, empty);
if self.run_revised {
self.push_str("[fmt]");
}
result
}
fn row_props(
&mut self,
reader: &mut Xml<'_>,
level: usize,
elem: &Elem,
empty: bool,
) -> Result<(), Fault> {
match elem.name.as_slice() {
b"ins" | b"moveTo" => self.row_mark(Mark::Inserted),
b"del" | b"moveFrom" => self.row_mark(Mark::Deleted),
b"gridBefore" => {
if let Some(table) = self.tables.last_mut() {
table.column = table
.column
.saturating_add(numeric_attr(elem, MAX_TABLE_COLUMNS))
.min(MAX_TABLE_COLUMNS);
}
}
b"rPr" => return self.discarded(reader, level, empty),
_ => {}
}
self.ignored(reader, level, empty)
}
fn cell_props(
&mut self,
reader: &mut Xml<'_>,
level: usize,
elem: &Elem,
empty: bool,
) -> Result<(), Fault> {
let restart = elem.attr("val") == Some("restart");
match elem.name.as_slice() {
b"gridSpan" => {
if let Some(cell) = self.cells.last_mut() {
cell.span = numeric_attr(elem, MAX_TABLE_COLUMNS).max(1);
}
}
b"vMerge" => {
if let Some(cell) = self.cells.last_mut() {
cell.merge = if restart {
Merge::Restart
} else {
Merge::Continue
};
}
}
b"hMerge" => {
if let Some(cell) = self.cells.last_mut() {
cell.hmerge = if restart {
Merge::Restart
} else {
Merge::Continue
};
}
}
b"cellIns" => self.cell_mark(Mark::Inserted),
b"cellDel" => self.cell_mark(Mark::Deleted),
b"cellMerge" => self.cell_mark(Mark::MergeRevised),
_ => {}
}
self.ignored(reader, level, empty)
}
fn section_props(
&mut self,
reader: &mut Xml<'_>,
level: usize,
elem: &Elem,
empty: bool,
) -> Result<(), Fault> {
match elem.name.as_slice() {
b"headerReference" => self.reference_part(elem, true),
b"footerReference" => self.reference_part(elem, false),
_ => {}
}
self.ignored(reader, level, empty)
}
fn change_marker(&mut self, ctx: Ctx, name: &[u8]) {
if !self.counting {
return;
}
match ctx {
Ctx::RunProps => self.run_revised = true,
Ctx::MarkProps | Ctx::ParagraphProps => self.para_mark("[fmt ¶]"),
Ctx::SectionProps => self.section_mark(),
Ctx::TableProps => self.table_mark(),
Ctx::RowProps => self.row_mark(Mark::FormatRevised),
Ctx::CellProps => self.cell_mark(Mark::FormatRevised),
_ if name == b"tblGridChange" => self.table_mark(),
_ => {}
}
}
fn table_mark(&mut self) {
if let Some(table) = self.tables.last_mut() {
table.format_revised = true;
}
}
fn section_mark(&mut self) {
if self.paras.is_empty() {
self.push_str("[fmt section]");
} else {
self.para_mark("[fmt section]");
}
}
fn para_mark(&mut self, mark: &'static str) {
if let Some(marks) = self.paras.last_mut() {
marks.push(mark);
}
}
fn row_mark(&mut self, mark: Mark) {
if let Some(table) = self.tables.last_mut() {
table.row_marks.push(mark);
}
}
fn cell_mark(&mut self, mark: Mark) {
if let Some(cell) = self.cells.last_mut() {
cell.marks.push(mark);
}
}
fn table(&mut self, reader: &mut Xml<'_>, level: usize, empty: bool) -> Result<(), Fault> {
if empty {
return Ok(());
}
self.tables.push(Table {
number: self.tables_opened + 1 - self.tables_empty,
..Table::default()
});
self.tables_opened += 1;
let result = self.content(reader, level + 1, Ctx::Flow);
self.finish_table();
result
}
fn finish_table(&mut self) {
let Some(table) = self.tables.pop() else {
return;
};
if table.rows.is_empty() {
self.tables_empty += 1;
return;
}
let columns = table.columns.max(table.grid);
let label = if table.format_revised {
format!("Table {} (formatting revised)", table.number)
} else {
format!("Table {}", table.number)
};
let mut text = format!(
"{label}: {}, {}",
plural(table.rows.len(), "row"),
plural(columns, "column")
);
for row in &table.rows {
text.push('\n');
text.push_str(row);
}
self.block(&text);
}
fn table_grid(&mut self, reader: &mut Xml<'_>, level: usize, empty: bool) -> Result<(), Fault> {
if empty {
return Ok(());
}
let mut buffer = Vec::new();
let mut columns = 0;
loop {
match next_event(reader, &mut buffer) {
Ev::Start(elem) => {
self.record(&elem);
if self.grid_child(&elem) {
columns += 1;
}
self.ignored(reader, level + 1, false)?;
}
Ev::Empty(elem) => {
self.record(&elem);
if self.grid_child(&elem) {
columns += 1;
}
}
Ev::End | Ev::Eof => break,
Ev::Fault => return Err(Fault),
Ev::Text(_) | Ev::Skip => {}
}
buffer.clear();
}
if let Some(table) = self.tables.last_mut() {
table.grid = columns.min(MAX_TABLE_COLUMNS);
}
Ok(())
}
fn grid_child(&mut self, elem: &Elem) -> bool {
match elem.name.as_slice() {
b"gridCol" => true,
b"tblGridChange" => {
self.table_mark();
false
}
_ => false,
}
}
fn row(&mut self, reader: &mut Xml<'_>, level: usize, empty: bool) -> Result<(), Fault> {
if empty {
return Ok(());
}
if let Some(table) = self.tables.last_mut() {
table.column = 1;
table.hmerge = None;
table.cells.clear();
table.row_marks.clear();
}
let result = self.content(reader, level + 1, Ctx::Flow);
if let Some(table) = self.tables.last_mut() {
let mut line = format!(" R{}", table.rows.len() + 1);
if !table.row_marks.is_empty() {
table.row_marks.sort();
table.row_marks.dedup();
let names: Vec<&str> = table.row_marks.iter().map(|mark| mark.text()).collect();
let _ = write!(line, " ({})", names.join(", "));
}
line.push(':');
if !table.cells.is_empty() {
line.push(' ');
line.push_str(&table.cells.join(" | "));
}
table.rows.push(line);
}
result
}
fn cell(&mut self, reader: &mut Xml<'_>, level: usize, empty: bool) -> Result<(), Fault> {
if empty {
return Ok(());
}
let column = self.tables.last().map_or(1, |table| table.column);
self.cells.push(Cell {
column,
span: 1,
..Cell::default()
});
let text = self.render_block(reader, level);
let cell = self.cells.pop().unwrap_or_default();
let text = text?;
if let Some(table) = self.tables.last_mut() {
table.place(cell, &text);
}
Ok(())
}
fn revision(
&mut self,
reader: &mut Xml<'_>,
level: usize,
elem: &Elem,
empty: bool,
) -> Result<(), Fault> {
if empty {
return Ok(());
}
let (open, close) = match elem.name.as_slice() {
b"ins" => ("[ins]", "[/ins]"),
b"del" => ("[del]", "[/del]"),
b"moveTo" => ("[moved-here]", "[/moved-here]"),
_ => ("[moved-away]", "[/moved-away]"),
};
let before = self.buffers.last().map_or(0, String::len);
let result = self.content(reader, level + 1, Ctx::Flow);
if let Some(sink) = self.buffers.last_mut()
&& sink.len() > before
{
sink.insert_str(before, open);
sink.push_str(close);
}
result
}
fn note_reference(&mut self, elem: &Elem, kind: NoteKind) {
let Some(id) = elem.attr("id") else {
return;
};
let number = self.notes_mut(kind).reference(id);
let marker = note_marker(kind.marker(), number, "");
self.push_str(&marker);
}
fn comment_marker(&mut self, elem: &Elem) {
let Some(id) = elem.attr("id") else {
return;
};
let suffix = match elem.name.as_slice() {
b"commentRangeStart" => {
self.comment_ranges.insert(id.to_owned());
" starts"
}
b"commentRangeEnd" => " ends",
_ if self.comment_ranges.contains(id) => return,
_ => "",
};
let number = self.notes_mut(NoteKind::Comment).reference(id);
let marker = note_marker(NoteKind::Comment.marker(), number, suffix);
self.push_str(&marker);
}
fn simple_field(
&mut self,
reader: &mut Xml<'_>,
level: usize,
elem: &Elem,
empty: bool,
) -> Result<(), Fault> {
let marker = elem
.attr("instr")
.map_or_else(|| "[field]".to_owned(), field_marker);
self.push_str(&marker);
self.flow(reader, level, empty)
}
fn field_char(&mut self, elem: &Elem) {
match elem.attr("fldCharType") {
Some("begin") => self.fields.push(Field {
pending: true,
..Field::default()
}),
Some("separate") => {
let Some(field) = self.fields.last_mut() else {
return;
};
field.pending = false;
field.held.clear();
let marker = field_marker(&field.instr);
self.push_str(&marker);
}
Some("end") => {
let Some(field) = self.fields.pop() else {
return;
};
if field.pending {
self.close_field(&field);
}
}
_ => {}
}
}
fn close_field(&mut self, field: &Field) {
let marker = field_marker(&field.instr);
self.push_str(&marker);
self.push_str(&field.held);
}
fn close_fields(&mut self) {
for field in std::mem::take(&mut self.fields) {
if field.pending {
self.close_field(&field);
}
}
}
fn instr_text(&mut self, reader: &mut Xml<'_>, level: usize, empty: bool) -> Result<(), Fault> {
if empty {
return Ok(());
}
let mut buffer = Vec::new();
let mut text = String::new();
loop {
match next_event(reader, &mut buffer) {
Ev::Text(piece) => text.push_str(&piece),
Ev::Start(_) => self.ignored(reader, level + 1, false)?,
Ev::End | Ev::Eof => break,
Ev::Fault => return Err(Fault),
Ev::Empty(_) | Ev::Skip => {}
}
buffer.clear();
}
if let Some(field) = self.fields.last_mut() {
field.instr.push_str(&text);
}
Ok(())
}
fn text_box(&mut self, reader: &mut Xml<'_>, level: usize, empty: bool) -> Result<(), Fault> {
if empty {
return Ok(());
}
self.text_boxes += 1;
let inner = self.render_block(reader, level);
self.text_boxes = self.text_boxes.saturating_sub(1);
let inner = inner?.trim_end().to_owned();
if inner.trim().is_empty() {
return Ok(());
}
self.block(&text_lines(TEXT_BOX, &lines_of(&inner)));
Ok(())
}
fn alternate_content(
&mut self,
reader: &mut Xml<'_>,
level: usize,
empty: bool,
) -> Result<(), Fault> {
if empty {
return Ok(());
}
let mut buffer = Vec::new();
let mut choice: Option<String> = None;
let mut fallback: Option<String> = None;
let base = self.revisions.mark();
loop {
match next_event(reader, &mut buffer) {
Ev::Start(elem) if elem.name == b"Choice" => {
if renders_nothing(choice.as_deref()) {
self.revisions.rollback(base);
choice = Some(self.render_block(reader, level + 1)?);
} else {
self.discarded(reader, level + 1, false)?;
}
}
Ev::Start(elem) if elem.name == b"Fallback" => {
if renders_nothing(fallback.as_deref()) && renders_nothing(choice.as_deref()) {
self.revisions.rollback(base);
fallback = Some(self.render_block(reader, level + 1)?);
} else {
self.discarded(reader, level + 1, false)?;
}
}
Ev::Start(_) => self.discarded(reader, level + 1, false)?,
Ev::End | Ev::Eof => break,
Ev::Fault => return Err(Fault),
Ev::Text(_) | Ev::Empty(_) | Ev::Skip => {}
}
buffer.clear();
}
let kept = if renders_nothing(choice.as_deref()) {
fallback
} else {
choice
};
match kept {
Some(text) if !renders_nothing(Some(text.as_str())) => self.block(text.trim()),
_ => self.revisions.rollback(base),
}
Ok(())
}
fn render_block(&mut self, reader: &mut Xml<'_>, level: usize) -> Result<String, Fault> {
self.buffers.push(String::new());
let result = self.content(reader, level + 1, Ctx::Flow);
self.close_fields();
let text = self.buffers.pop().unwrap_or_default();
result.map(|()| text)
}
fn reference_part(&mut self, elem: &Elem, header: bool) {
if !self.collect_sections || self.text_boxes > 0 {
return;
}
let label = match elem.attr("type") {
Some("first") => "first page",
Some("even") => "even pages",
_ => "default",
};
let Some(target) = elem
.attr("id")
.and_then(|id| self.rel(id))
.map(str::to_owned)
else {
return;
};
let part = resolve_part(DOCX_BASE, &target);
let list = if header {
&mut self.headers
} else {
&mut self.footers
};
list.record(part, label);
}
fn extra_sections<R: Read + Seek>(&mut self, archive: &mut ZipArchive<R>) -> Vec<String> {
let headers = std::mem::take(&mut self.headers);
let footers = std::mem::take(&mut self.footers);
let mut blocks = self.part_blocks(archive, &headers.parts, true);
blocks.extend(self.part_blocks(archive, &footers.parts, false));
for kind in [NoteKind::Footnote, NoteKind::Endnote, NoteKind::Comment] {
self.read_definitions(kind, archive);
blocks.extend(self.note_blocks(kind));
}
blocks
}
fn part_blocks<R: Read + Seek>(
&mut self,
archive: &mut ZipArchive<R>,
refs: &[PartRef],
header: bool,
) -> Vec<String> {
let mut rendered: Vec<(&[&'static str], String)> = Vec::new();
for entry in refs {
let Some(xml) = read_zip_entry(archive, &entry.part).bytes() else {
continue;
};
let mark = self.revisions.mark();
match self.read_part(&xml, false) {
Ok(text) if !text.trim().is_empty() => rendered.push((&entry.labels, text)),
_ => self.revisions.rollback(mark),
}
}
let name = if header { WORD_HEADER } else { WORD_FOOTER };
let numbered = rendered.len() > 1;
rendered
.into_iter()
.enumerate()
.map(|(index, (labels, text))| {
let label = if numbered {
format!("{name} {}", index + 1)
} else {
name.to_owned()
};
labeled_block(&format!("{label} ({})", labels.join(", ")), &text)
})
.collect()
}
fn read_definitions<R: Read + Seek>(&mut self, kind: NoteKind, archive: &mut ZipArchive<R>) {
let part = self.notes(kind).part.clone();
let Some(xml) = read_zip_entry(archive, &part).bytes() else {
return;
};
self.reset_part();
let mut reader = Reader::from_reader(xml.as_slice());
let _ = self.definitions(&mut reader, 0, kind);
}
fn definitions(
&mut self,
reader: &mut Xml<'_>,
level: usize,
kind: NoteKind,
) -> Result<(), Fault> {
if level >= MAX_ELEMENT_DEPTH {
return Err(Fault);
}
let mut buffer = Vec::new();
loop {
match next_event(reader, &mut buffer) {
Ev::Start(elem) if elem.name == kind.element() => {
self.definition(reader, level + 1, kind, &elem, false)?;
}
Ev::Empty(elem) if elem.name == kind.element() => {
self.definition(reader, level + 1, kind, &elem, true)?;
}
Ev::Start(_) => self.definitions(reader, level + 1, kind)?,
Ev::End | Ev::Eof => return Ok(()),
Ev::Fault => return Err(Fault),
Ev::Text(_) | Ev::Empty(_) | Ev::Skip => {}
}
buffer.clear();
}
}
fn definition(
&mut self,
reader: &mut Xml<'_>,
level: usize,
kind: NoteKind,
elem: &Elem,
empty: bool,
) -> Result<(), Fault> {
let id = elem.attr("id").unwrap_or_default().to_owned();
let author = elem.attr("author").map(str::to_owned);
if !keeps_definition(kind, &id, elem.attr("type")) {
return self.ignored(reader, level, empty);
}
let mark = self.revisions.mark();
let text = if empty {
String::new()
} else {
match self.render_block(reader, level) {
Ok(text) => text.trim_end().to_owned(),
Err(fault) => {
self.revisions.rollback(mark);
return Err(fault);
}
}
};
if text.trim().is_empty() && !self.notes(kind).referenced.contains(&id) {
self.revisions.rollback(mark);
return Ok(());
}
let notes = self.notes_mut(kind);
let number = notes.number(&id);
notes.defs.push(Definition {
id,
author,
text,
number,
});
Ok(())
}
fn note_blocks(&self, kind: NoteKind) -> Vec<String> {
let notes = self.notes(kind);
let mut defs: Vec<&Definition> = notes.defs.iter().collect();
defs.sort_by_key(|definition| definition.number);
defs.into_iter()
.map(|definition| {
let mut extras: Vec<String> = Vec::new();
if let Some(author) = &definition.author {
extras.push(author.clone());
}
if !notes.referenced.contains(&definition.id) {
extras.push("not referenced in the text".to_owned());
}
let mut label = format!("{} {}", kind.label(), definition.number);
if !extras.is_empty() {
let _ = write!(label, " ({})", extras.join(", "));
}
if definition.text.is_empty() {
return format!("{label}:");
}
labeled_block(&label, &definition.text)
})
.collect()
}
fn revision_summary(&self) -> Option<String> {
let tallies = self.revisions.tallies;
let mut parts: Vec<String> = Vec::new();
for (count, noun) in [
(tallies.insertions, "insertion"),
(tallies.deletions, "deletion"),
(tallies.moves(), "move"),
(tallies.formatting, "formatting change"),
] {
if count > 0 {
parts.push(plural(count, noun));
}
}
if parts.is_empty() {
return None;
}
let mut line = format!("Unaccepted tracked changes: {}", parts.join(", "));
if !self.revisions.authors.is_empty() {
let _ = write!(line, " (authors: {})", self.revisions.authors.join(", "));
}
line.push('.');
Some(line)
}
}
#[derive(Default)]
struct Table {
rows: Vec<String>,
number: usize,
columns: usize,
grid: usize,
column: usize,
merges: Vec<Option<(usize, usize)>>,
cells: Vec<String>,
row_marks: Vec<Mark>,
hmerge: Option<usize>,
format_revised: bool,
}
impl Table {
fn place(&mut self, cell: Cell, text: &str) {
let row = self.rows.len() + 1;
let column = cell.column.min(MAX_TABLE_COLUMNS);
let span = cell.span.max(1).min(MAX_TABLE_COLUMNS + 1 - column);
let mut holds_value = true;
let mut origin: Option<(usize, usize)> = None;
match cell.merge {
Merge::Restart => {
for covered in column..column + span {
set_merge(&mut self.merges, covered, Some((row, column)));
}
}
Merge::Continue => {
origin = self.merges.get(column).copied().flatten();
holds_value = origin.is_none();
}
Merge::None => {
for covered in column..column + span {
set_merge(&mut self.merges, covered, None);
}
match cell.hmerge {
Merge::Restart => self.hmerge = Some(column),
Merge::Continue => {
origin = self.hmerge.map(|origin| (row, origin));
holds_value = origin.is_none();
}
Merge::None => {}
}
}
}
let mut notes: Vec<String> = Vec::new();
if let Some((origin_row, origin_column)) = origin {
notes.push(format!("part of R{origin_row}C{origin_column}"));
}
if span > 1 {
notes.push(format!("spans C{column}-C{}", column + span - 1));
}
let mut marks = cell.marks;
marks.sort();
marks.dedup();
notes.extend(marks.iter().map(|mark| mark.text().to_owned()));
let value = if holds_value {
text.trim().replace('\n', " ")
} else {
String::new()
};
let mut token = format!("C{column}");
if !notes.is_empty() {
let _ = write!(token, " ({})", notes.join(", "));
}
token.push(':');
if !value.is_empty() {
token.push(' ');
token.push_str(&value);
}
self.cells.push(token);
self.column = column + span;
self.columns = self.columns.max(column + span - 1);
}
}
fn set_merge(
merges: &mut Vec<Option<(usize, usize)>>,
column: usize,
origin: Option<(usize, usize)>,
) {
if column >= merges.len() {
merges.resize(column + 1, None);
}
merges[column] = origin;
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Mark {
Inserted,
Deleted,
MergeRevised,
FormatRevised,
}
impl Mark {
fn text(self) -> &'static str {
match self {
Self::Inserted => "inserted",
Self::Deleted => "deleted",
Self::MergeRevised => "merge revised",
Self::FormatRevised => "formatting revised",
}
}
}
#[derive(Default)]
struct Cell {
column: usize,
span: usize,
merge: Merge,
hmerge: Merge,
marks: Vec<Mark>,
}
#[derive(Default, Clone, Copy, PartialEq, Eq)]
enum Merge {
#[default]
None,
Restart,
Continue,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum NoteKind {
Footnote,
Endnote,
Comment,
}
impl NoteKind {
fn element(self) -> &'static [u8] {
match self {
Self::Footnote => b"footnote",
Self::Endnote => b"endnote",
Self::Comment => b"comment",
}
}
fn label(self) -> &'static str {
match self {
Self::Footnote => WORD_FOOTNOTE,
Self::Endnote => WORD_ENDNOTE,
Self::Comment => WORD_COMMENT,
}
}
fn marker(self) -> &'static str {
match self {
Self::Footnote => "footnote",
Self::Endnote => "endnote",
Self::Comment => "comment",
}
}
}
fn notes_part(rels: &[Relationship], kind: NoteKind) -> String {
let entry = crate::docgen::docx_peripheral_part(kind.marker());
rels.iter()
.find(|rel| rel.kind.ends_with(&entry.rel))
.map_or_else(
|| {
entry
.part
.clone()
.expect("a note kind's shared entry names its conventional part")
},
|rel| resolve_part(DOCX_BASE, &rel.target),
)
}
#[derive(Default)]
struct Notes {
defs: Vec<Definition>,
defined: HashSet<String>,
numbers: HashMap<String, usize>,
referenced: HashSet<String>,
next: usize,
part: String,
}
impl Notes {
fn load<R: Read + Seek>(kind: NoteKind, part: &str, archive: &mut ZipArchive<R>) -> Self {
let mut notes = Self {
part: part.to_owned(),
..Self::default()
};
if let Some(xml) = read_zip_entry(archive, part).bytes() {
notes.defined = definition_ids(&xml, kind).into_iter().collect();
}
notes
}
fn reference(&mut self, id: &str) -> Option<usize> {
if !self.defined.contains(id) {
return None;
}
self.referenced.insert(id.to_owned());
Some(self.number(id))
}
fn number(&mut self, id: &str) -> usize {
if let Some(&number) = self.numbers.get(id) {
return number;
}
self.next += 1;
self.numbers.insert(id.to_owned(), self.next);
self.next
}
}
struct Definition {
id: String,
author: Option<String>,
text: String,
number: usize,
}
fn keeps_definition(kind: NoteKind, id: &str, type_attr: Option<&str>) -> bool {
if kind != NoteKind::Comment && type_attr.is_some_and(|value| value != "normal") {
return false;
}
id.parse::<i64>().is_ok_and(|number| number >= 0)
}
fn definition_ids(xml: &[u8], kind: NoteKind) -> Vec<String> {
scan_elements(xml, kind.element(), |event| {
let id = attr(event, b"id")?;
let type_attr = attr(event, b"type");
keeps_definition(kind, &id, type_attr.as_deref()).then_some(id)
})
.unwrap_or_default()
}
struct PartRef {
part: String,
labels: Vec<&'static str>,
}
#[derive(Default)]
struct PartRefs {
parts: Vec<PartRef>,
index: HashMap<String, usize>,
}
impl PartRefs {
fn record(&mut self, part: String, label: &'static str) {
if let Some(&position) = self.index.get(&part) {
let labels = &mut self.parts[position].labels;
if !labels.contains(&label) {
labels.push(label);
}
return;
}
self.index.insert(part.clone(), self.parts.len());
self.parts.push(PartRef {
part,
labels: vec![label],
});
}
}
#[derive(Default)]
struct Field {
instr: String,
pending: bool,
held: String,
}
fn field_marker(instruction: &str) -> String {
let mut capped = String::new();
let mut chars = 0;
for word in instruction.split_whitespace() {
let separator = usize::from(!capped.is_empty());
let room = MAX_FIELD_INSTRUCTION_CHARS.saturating_sub(chars + separator);
if room == 0 {
break;
}
if separator == 1 {
capped.push(' ');
chars += 1;
}
capped.extend(word.chars().take(room));
chars += word.chars().count().min(room);
}
if capped.is_empty() {
"[field]".to_owned()
} else {
format!("[field {capped}]")
}
}
#[derive(Default)]
struct Revisions {
tallies: Tallies,
authors: Vec<Rc<str>>,
seen_authors: HashSet<Rc<str>>,
}
#[derive(Clone, Copy, Default)]
struct Tallies {
insertions: usize,
deletions: usize,
moved_here: usize,
moved_away: usize,
move_ranges: usize,
formatting: usize,
}
impl Tallies {
fn moves(&self) -> usize {
if self.moved_here == 0 && self.moved_away == 0 {
return 0;
}
if self.move_ranges > 0 {
self.move_ranges
} else if self.moved_here > 0 {
self.moved_here
} else {
self.moved_away
}
}
}
#[derive(Clone, Copy)]
struct RevisionsMark {
tallies: Tallies,
authors: usize,
}
fn is_property_change(name: &[u8]) -> bool {
matches!(
name,
b"rPrChange"
| b"pPrChange"
| b"tblPrChange"
| b"tblPrExChange"
| b"tblGridChange"
| b"trPrChange"
| b"tcPrChange"
| b"sectPrChange"
)
}
impl Revisions {
fn record(&mut self, elem: &Elem) {
match elem.name.as_slice() {
b"ins" | b"cellIns" => self.tallies.insertions += 1,
b"del" | b"cellDel" => self.tallies.deletions += 1,
b"moveTo" => self.tallies.moved_here += 1,
b"moveFrom" => self.tallies.moved_away += 1,
b"moveFromRangeStart" => self.tallies.move_ranges += 1,
b"cellMerge" => self.tallies.formatting += 1,
name if is_property_change(name) => self.tallies.formatting += 1,
_ => return,
}
let Some(author) = elem.attr("author") else {
return;
};
if self.seen_authors.contains(author) {
return;
}
let author: Rc<str> = Rc::from(author);
self.seen_authors.insert(Rc::clone(&author));
self.authors.push(author);
}
fn mark(&self) -> RevisionsMark {
RevisionsMark {
tallies: self.tallies,
authors: self.authors.len(),
}
}
fn rollback(&mut self, mark: RevisionsMark) {
self.tallies = mark.tallies;
for author in self.authors.drain(mark.authors..) {
self.seen_authors.remove(&author);
}
}
}
fn note_marker(kind: &str, number: Option<usize>, suffix: &str) -> String {
match number {
Some(number) => format!("[{kind} {number}{suffix}]"),
None => format!("[{kind} ?{suffix}]"),
}
}
fn plural(count: usize, noun: &str) -> String {
if count == 1 {
format!("{count} {noun}")
} else {
format!("{count} {noun}s")
}
}
fn renders_nothing(text: Option<&str>) -> bool {
text.is_none_or(|text| text.trim().is_empty())
}
fn numeric_attr(elem: &Elem, max: usize) -> usize {
elem.attr("val")
.and_then(|value| value.parse().ok())
.unwrap_or(0)
.min(max)
}
#[must_use]
pub(crate) fn convert_docx(bytes: &[u8], out_dir: &Path) -> DocOutcome {
let Ok(mut archive) = ZipArchive::new(Cursor::new(bytes)) else {
return unreadable(".docx");
};
let Some(body_xml) = read_zip_entry(&mut archive, DOCX_BODY_PART).bytes() else {
return unreadable(".docx");
};
let rels = read_zip_entry(&mut archive, DOCX_RELS_PART)
.bytes()
.and_then(|xml| relationships(&xml))
.unwrap_or_default();
let mut word = Word::new(&rels, &mut archive);
let Some(body) = word.body(&body_xml) else {
return unreadable(".docx");
};
drop(body_xml);
let blocks = word.extra_sections(&mut archive);
let mut sections: Vec<String> = Vec::new();
if let Some(summary) = word.revision_summary() {
sections.push(format!("{summary}\n"));
}
let body = body.trim();
if !body.is_empty() {
sections.push(body.to_owned());
}
sections.extend(blocks);
let text = sections.join("\n").trim().to_owned();
ensure_out_dir(out_dir);
let mut unshown = unopened_parts(
&mut archive,
DOCX_CHARTS_PREFIX,
Some(DOCX_DIAGRAMS_PREFIX),
DOCX_EMBEDDINGS_PREFIX,
None, );
let mut skipped = SkippedImages::default();
let images = write_media_parts(
&mut archive,
DOCX_MEDIA_PREFIX,
out_dir,
&mut skipped,
&mut unshown,
);
DocOutcome::Text {
text,
images,
notes: skipped.notes(),
unshown,
all_page_text_lost: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ooxml::test_fixtures::{DOCX_BODY, zip_fixture};
const NAMESPACES: &str = concat!(
r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" "#,
r#"xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" "#,
r#"xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" "#,
r#"xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" "#,
r#"xmlns:v="urn:schemas-microsoft-com:vml""#,
);
fn document(body: &str) -> String {
format!("<w:document {NAMESPACES}><w:body>{body}</w:body></w:document>")
}
fn rels(entries: &str) -> String {
format!("<Relationships>{entries}</Relationships>")
}
fn rel(id: &str, kind: &str, target: &str) -> String {
let base = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
format!(r#"<Relationship Id="{id}" Type="{base}/{kind}" Target="{target}"/>"#)
}
fn package(body: &str, parts: &[(&str, &str)]) -> Vec<u8> {
let document = document(body);
let mut entries: Vec<(&str, &[u8])> = vec![("word/document.xml", document.as_bytes())];
entries.extend(parts.iter().map(|(path, xml)| (*path, xml.as_bytes())));
zip_fixture(&entries)
}
fn text_of(bytes: &[u8]) -> String {
let dir = tempfile::tempdir().expect("tempdir");
match convert_docx(bytes, dir.path()) {
DocOutcome::Text { text, .. } => text,
_ => panic!("expected a Text outcome for a well-formed docx"),
}
}
#[test]
fn docx_extracts_paragraphs_and_media() {
let bytes = zip_fixture(&[
("word/document.xml", DOCX_BODY),
("word/media/pic.png", b"\x89PNG\r\n\x1a\nfake image bytes"),
("word/media/", b""),
]);
let dir = tempfile::tempdir().expect("tempdir");
let DocOutcome::Text {
text,
images,
notes,
..
} = convert_docx(&bytes, dir.path())
else {
panic!("expected Text outcome for a well-formed docx");
};
assert_eq!(text, "First paragraph\nSecond paragraph");
assert_eq!(images, vec![dir.path().join("pic.png")]);
assert!(notes.is_empty());
assert_eq!(
std::fs::read(&images[0]).expect("read written image"),
b"\x89PNG\r\n\x1a\nfake image bytes"
);
}
#[test]
fn docx_reports_media_it_cannot_convert() {
let bytes = zip_fixture(&[
("word/document.xml", DOCX_BODY),
(
"word/media/diagram.emf",
b"EMF bytes this stack cannot decode",
),
(
"word/media/vector.wmf",
b"WMF bytes this stack cannot decode",
),
]);
let dir = tempfile::tempdir().expect("tempdir");
let DocOutcome::Text {
images,
notes,
unshown,
..
} = convert_docx(&bytes, dir.path())
else {
panic!("expected Text outcome for a well-formed docx");
};
assert!(images.is_empty(), "an undecodable entry yields no image");
assert_eq!(unshown.lines(), ["2 embedded drawing(s) not shown"]);
assert!(notes.is_empty(), "the report carries the whole loss");
}
#[test]
fn docx_reports_the_parts_its_reading_does_not_open() {
let bytes = zip_fixture(&[
("word/document.xml", DOCX_BODY),
("word/diagrams/data1.xml", b"<dgm:dataModel/>"),
("word/diagrams/layout1.xml", b"<dgm:layoutDef/>"),
("word/embeddings/oleObject1.bin", b"OLE bytes"),
(
"word/embeddings/Microsoft_Excel_Worksheet1.xlsx",
b"chart data workbook",
),
("word/charts/chart1.xml", b"<chart/>"),
("word/charts/chartEx1.xml", b"<chartEx/>"),
(
"word/charts/_rels/chart1.xml.rels",
br#"<Relationships><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet1.xlsx"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet9.xlsx"/></Relationships>"#,
),
]);
let dir = tempfile::tempdir().expect("tempdir");
let DocOutcome::Text { notes, unshown, .. } = convert_docx(&bytes, dir.path()) else {
panic!("expected Text outcome for a well-formed docx");
};
assert_eq!(
unshown.lines(),
[
"2 chart(s) not shown",
"1 diagram(s) not shown",
"1 embedded object part(s) not shown",
]
);
assert!(
notes.is_empty(),
"the report carries the whole loss: {notes:?}"
);
}
#[test]
fn docx_renders_table_rows_cells_and_merges() {
let body = r#"<w:p><w:r><w:t>Before</w:t></w:r></w:p><w:tbl>
<w:tblGrid><w:gridCol/><w:gridCol/><w:gridCol/></w:tblGrid>
<w:tr>
<w:tc><w:tcPr><w:vMerge w:val="restart"/><w:gridSpan w:val="2"/></w:tcPr><w:p><w:r><w:t>Merged</w:t></w:r></w:p></w:tc>
<w:tc><w:p><w:r><w:t>X</w:t></w:r></w:p></w:tc>
</w:tr>
<w:tr>
<w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc>
<w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc>
<w:tc><w:p><w:r><w:t>Y</w:t></w:r></w:p></w:tc>
</w:tr>
</w:tbl>"#;
assert_eq!(
text_of(&package(body, &[])),
"Before\n\
Table 1: 2 rows, 3 columns\n \
R1: C1 (spans C1-C2): Merged | C3: X\n \
R2: C1 (part of R1C1): | C2 (part of R1C1): | C3: Y"
);
}
#[test]
fn docx_spans_a_merge_without_a_vertical_one() {
let body = r#"<w:tbl><w:tblGrid><w:gridCol/><w:gridCol/><w:gridCol/></w:tblGrid>
<w:tr><w:tc><w:tcPr><w:gridSpan w:val="2"/></w:tcPr><w:p><w:r><w:t>Merged header</w:t></w:r></w:p></w:tc>
<w:tc><w:p><w:r><w:t>Third</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>"#;
assert_eq!(
text_of(&package(body, &[])),
"Table 1: 1 row, 3 columns\n R1: C1 (spans C1-C2): Merged header | C3: Third"
);
}
#[test]
fn docx_does_not_repeat_a_merge_continuation_value() {
let body = r#"<w:tbl><w:tblGrid><w:gridCol/></w:tblGrid>
<w:tr><w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>Merged</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p><w:r><w:t>Merged</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>"#;
assert_eq!(
text_of(&package(body, &[])),
"Table 1: 2 rows, 1 column\n \
R1: C1: Merged\n \
R2: C1 (part of R1C1):"
);
}
#[test]
fn docx_clamps_document_controlled_table_geometry() {
let body = r#"<w:tbl><w:tblGrid><w:gridCol/></w:tblGrid>
<w:tr><w:tc><w:tcPr><w:gridSpan w:val="4000000000"/></w:tcPr><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:trPr><w:gridBefore w:val="4000000000"/></w:trPr><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>"#;
assert_eq!(
text_of(&package(body, &[])),
"Table 1: 2 rows, 256 columns\n \
R1: C1 (spans C1-C256): A\n \
R2: C256: B"
);
}
#[test]
fn docx_bounds_the_geometry_a_row_of_wide_cells_adds_up_to() {
let cell = |text: &str| {
format!(
r#"<w:tc><w:tcPr><w:gridSpan w:val="4000000000"/></w:tcPr>\
<w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:tc>"#
)
};
let body = format!(
"<w:tbl><w:tr>{}{}{}{}</w:tr></w:tbl>",
cell("A"),
cell("B"),
cell("C"),
cell("D")
);
assert_eq!(
text_of(&package(&body, &[])),
"Table 1: 1 row, 256 columns\n \
R1: C1 (spans C1-C256): A | C256: B | C256: C | C256: D"
);
}
#[test]
fn docx_numbers_a_nested_table_after_the_one_that_holds_it() {
let body = r"<w:tbl><w:tblGrid><w:gridCol/></w:tblGrid>
<w:tr><w:tc>
<w:p><w:r><w:t>outer</w:t></w:r></w:p>
<w:tbl><w:tblGrid><w:gridCol/></w:tblGrid>
<w:tr><w:tc><w:p><w:r><w:t>inner</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
</w:tc></w:tr>
</w:tbl>";
assert_eq!(
text_of(&package(body, &[])),
"Table 1: 1 row, 1 column\n \
R1: C1: outer Table 2: 1 row, 1 column R1: C1: inner"
);
}
#[test]
fn docx_marks_inserted_and_deleted_rows() {
let body = r#"<w:tbl><w:tblGrid><w:gridCol/></w:tblGrid>
<w:tr><w:trPr><w:ins w:author="Ivan Petrov"/></w:trPr><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:trPr><w:del w:author="Anna"/></w:trPr><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>"#;
assert_eq!(
text_of(&package(body, &[])),
"Unaccepted tracked changes: 1 insertion, 1 deletion \
(authors: Ivan Petrov, Anna).\n\n\
Table 1: 2 rows, 1 column\n \
R1 (inserted): C1: A\n \
R2 (deleted): C1: B"
);
}
#[test]
fn docx_annotates_cells_and_a_revised_table() {
let body = r#"<w:tbl>
<w:tblPr><w:tblPrChange w:author="Anna"/></w:tblPr>
<w:tblGrid><w:gridCol/><w:gridCol/></w:tblGrid>
<w:tr>
<w:tc><w:tcPr><w:cellIns/><w:cellDel/><w:cellMerge/></w:tcPr><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc>
<w:tc><w:tcPr><w:tcPrChange w:author="Anna"/></w:tcPr><w:p/></w:tc>
</w:tr>
</w:tbl>"#;
assert_eq!(
text_of(&package(body, &[])),
"Unaccepted tracked changes: 1 insertion, 1 deletion, 3 formatting changes \
(authors: Anna).\n\n\
Table 1 (formatting revised): 1 row, 2 columns\n \
R1: C1 (inserted, deleted, merge revised): C | C2 (formatting revised):"
);
}
#[test]
fn docx_marks_a_row_property_exception() {
let body = r#"<w:tbl><w:tblGrid><w:gridCol/></w:tblGrid>
<w:tr><w:tblPrEx><w:tblPrExChange w:author="QA"/></w:tblPrEx><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:trPr><w:trPrChange w:author="QA"/></w:trPr><w:tblPrEx><w:tblPrExChange w:author="QA"/></w:tblPrEx><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>"#;
assert_eq!(
text_of(&package(body, &[])),
"Unaccepted tracked changes: 3 formatting changes (authors: QA).\n\n\
Table 1: 2 rows, 1 column\n \
R1 (formatting revised): C1: A\n \
R2 (formatting revised): C1: B"
);
}
#[test]
fn docx_marks_a_revised_grid() {
let body = r#"<w:tbl><w:tblGrid><w:gridCol/><w:tblGridChange w:author="QA"/></w:tblGrid>
<w:tr><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>"#;
assert_eq!(
text_of(&package(body, &[])),
"Unaccepted tracked changes: 1 formatting change (authors: QA).\n\n\
Table 1 (formatting revised): 1 row, 1 column\n R1: C1: A"
);
}
#[test]
fn docx_reads_header_and_footer_variants_and_a_field() {
let body = r#"<w:p><w:r><w:t>Body text</w:t></w:r></w:p><w:sectPr>
<w:headerReference w:type="default" r:id="rId1"/>
<w:headerReference w:type="first" r:id="rId2"/>
<w:headerReference w:type="default" r:id="rId1"/>
<w:footerReference w:type="even" r:id="rId3"/>
</w:sectPr>"#;
let rels = rels(&format!(
"{}{}{}",
rel("rId1", "header", "header1.xml"),
rel("rId2", "header", "header2.xml"),
rel("rId3", "footer", "footer1.xml"),
));
let header1 = r"<w:hdr><w:p><w:r><w:t>Company</w:t></w:r></w:p></w:hdr>";
let header2 = r"<w:hdr><w:p><w:r><w:t>Draft</w:t></w:r></w:p></w:hdr>";
let footer = r#"<w:ftr><w:p><w:r><w:t xml:space="preserve">Page </w:t></w:r>\
<w:r><w:fldSimple w:instr=" PAGE "/></w:r>\
<w:r><w:t xml:space="preserve"> 5</w:t></w:r></w:p></w:ftr>"#;
let bytes = package(
body,
&[
("word/_rels/document.xml.rels", &rels),
("word/header1.xml", header1),
("word/header2.xml", header2),
("word/footer1.xml", footer),
],
);
assert_eq!(
text_of(&bytes),
"Body text\n\
Header 1 (default):\n Company\n\
Header 2 (first page):\n Draft\n\
Footer (even pages):\n Page [field PAGE] 5"
);
}
#[test]
fn docx_reads_a_body_whose_only_text_is_in_a_header() {
let body =
r#"<w:p/><w:sectPr><w:headerReference w:type="default" r:id="rId1"/></w:sectPr>"#;
let bytes = package(
body,
&[
(
"word/_rels/document.xml.rels",
&rels(&rel("rId1", "header", "header1.xml")),
),
(
"word/header1.xml",
r"<w:hdr><w:p><w:r><w:t>Only here</w:t></w:r></w:p></w:hdr>",
),
],
);
assert_eq!(text_of(&bytes), "Header (default):\n Only here");
}
#[test]
fn docx_numbers_tables_across_parts() {
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p>
<w:tbl><w:tblGrid><w:gridCol/></w:tblGrid>
<w:tr><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:sectPr><w:headerReference w:type="default" r:id="rId1"/></w:sectPr>"#;
let bytes = package(
body,
&[
(
"word/_rels/document.xml.rels",
&rels(&rel("rId1", "header", "header1.xml")),
),
(
"word/header1.xml",
r"<w:hdr><w:tbl><w:tblGrid><w:gridCol/></w:tblGrid>
<w:tr><w:tc><w:p><w:r><w:t>H</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl></w:hdr>",
),
],
);
assert_eq!(
text_of(&bytes),
"Body\n\
Table 1: 1 row, 1 column\n R1: C1: A\n\
Header (default):\n Table 2: 1 row, 1 column\n R1: C1: H"
);
}
#[test]
fn docx_does_not_tally_what_it_does_not_show() {
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p>
<w:sectPr><w:headerReference w:type="default" r:id="rId1"/></w:sectPr>"#;
let bytes = package(
body,
&[
(
"word/_rels/document.xml.rels",
&rels(&format!(
"{}{}",
rel("rId1", "header", "header1.xml"),
rel("rId2", "footnotes", "footnotes.xml"),
)),
),
(
"word/header1.xml",
r#"<w:hdr><w:p><w:r><w:ins w:author="Anna"/></w:r></w:p></w:hdr>"#,
),
(
"word/footnotes.xml",
r#"<w:footnotes><w:footnote w:id="1"><w:p><w:ins w:author="Anna"/></w:p></w:footnote></w:footnotes>"#,
),
],
);
assert_eq!(text_of(&bytes), "Body");
}
#[test]
fn docx_summarises_revisions_from_every_part() {
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r></w:p>
<w:sectPr><w:headerReference w:type="default" r:id="rId1"/></w:sectPr>"#;
let bytes = package(
body,
&[
(
"word/_rels/document.xml.rels",
&rels(&format!(
"{}{}",
rel("rId1", "header", "header1.xml"),
rel("rId2", "footnotes", "footnotes.xml"),
)),
),
(
"word/header1.xml",
r#"<w:hdr><w:p><w:ins w:author="Anna"><w:r><w:t>Draft</w:t></w:r></w:ins></w:p></w:hdr>"#,
),
(
"word/footnotes.xml",
r#"<w:footnotes><w:footnote w:id="1"><w:p><w:del w:author="Anna"><w:r><w:delText>gone</w:delText></w:r></w:del></w:p></w:footnote></w:footnotes>"#,
),
],
);
assert_eq!(
text_of(&bytes),
"Unaccepted tracked changes: 1 insertion, 1 deletion (authors: Anna).\n\n\
Body[footnote 1]\n\
Header (default):\n [ins]Draft[/ins]\n\
Footnote 1:\n [del]gone[/del]"
);
}
#[test]
fn docx_reads_footnotes_endnotes_and_comments() {
let body = r#"<w:p>\
<w:r><w:t xml:space="preserve">Claim </w:t></w:r>\
<w:r><w:endnoteReference w:id="1"/></w:r>\
<w:commentRangeStart w:id="2"/>\
<w:r><w:t>here</w:t></w:r>\
<w:commentRangeEnd w:id="2"/>\
<w:r><w:commentReference w:id="2"/></w:r>\
<w:r><w:footnoteReference w:id="1"/></w:r>\
<w:r><w:commentReference w:id="7"/></w:r></w:p>"#;
let footnotes = r#"<w:footnotes>\
<w:footnote w:id="-1"><w:p><w:r><w:t>separator</w:t></w:r></w:p></w:footnote>\
<w:footnote w:type="continuationSeparator" w:id="0"><w:p><w:r><w:t>continued</w:t></w:r></w:p></w:footnote>\
<w:footnote w:id="1"><w:p><w:r><w:t>see the appendix</w:t></w:r></w:p></w:footnote>\
</w:footnotes>"#;
let endnotes = r#"<w:endnotes>\
<w:endnote w:id="1"><w:p><w:r><w:t>appendix A</w:t></w:r></w:p></w:endnote>\
</w:endnotes>"#;
let comments = r#"<w:comments>\
<w:comment w:id="2" w:author="Ivan Petrov"><w:p><w:r><w:t>needs a citation</w:t></w:r></w:p></w:comment>\
<w:comment w:id="6" w:author="Anna"><w:p><w:r><w:t>orphan</w:t></w:r></w:p></w:comment>\
</w:comments>"#;
let bytes = package(
body,
&[
(
"word/_rels/document.xml.rels",
&rels(&rel("rId5", "footnotes", "notes/footnotes.xml")),
),
("word/notes/footnotes.xml", footnotes),
("word/endnotes.xml", endnotes),
("word/comments.xml", comments),
],
);
assert_eq!(
text_of(&bytes),
"Claim [endnote 1][comment 1 starts]here[comment 1 ends][footnote 1][comment ?]\n\
Footnote 1:\n see the appendix\n\
Endnote 1:\n appendix A\n\
Comment 1 (Ivan Petrov):\n needs a citation\n\
Comment 2 (Anna, not referenced in the text):\n orphan"
);
}
#[test]
fn docx_reports_a_referenced_definition_with_no_text() {
let body = r#"<w:p><w:r><w:t>Text</w:t></w:r>\
<w:r><w:footnoteReference w:id="1"/></w:r>\
<w:r><w:footnoteReference w:id="2"/></w:r></w:p>"#;
let footnotes = r#"<w:footnotes>\
<w:footnote w:id="1"><w:p/></w:footnote>\
<w:footnote w:id="2"><w:p><w:r><w:t>note</w:t></w:r></w:p></w:footnote>\
<w:footnote w:id="3"><w:p/></w:footnote>\
</w:footnotes>"#;
let bytes = package(
body,
&[
(
"word/_rels/document.xml.rels",
&rels(&rel("rId5", "footnotes", "footnotes.xml")),
),
("word/footnotes.xml", footnotes),
],
);
assert_eq!(
text_of(&bytes),
"Text[footnote 1][footnote 2]\n\
Footnote 1:\n\
Footnote 2:\n note"
);
}
#[test]
fn docx_resets_a_comment_range_between_parts() {
let body = r#"<w:p><w:commentRangeStart w:id="2"/>\
<w:r><w:t>body</w:t></w:r>\
<w:commentRangeEnd w:id="2"/>\
</w:p>\
<w:sectPr><w:headerReference w:type="default" r:id="rId1"/></w:sectPr>"#;
let bytes = package(
body,
&[
(
"word/_rels/document.xml.rels",
&rels(&format!(
"{}{}",
rel("rId1", "header", "header1.xml"),
rel("rId2", "comments", "comments.xml"),
)),
),
(
"word/header1.xml",
r#"<w:hdr><w:p><w:r><w:commentReference w:id="2"/></w:r></w:p></w:hdr>"#,
),
(
"word/comments.xml",
r#"<w:comments><w:comment w:id="2" w:author="Ivan Petrov"><w:p><w:r><w:t>note</w:t></w:r></w:p></w:comment></w:comments>"#,
),
],
);
assert_eq!(
text_of(&bytes),
"[comment 1 starts]body[comment 1 ends]\n\
Header (default):\n [comment 1]\n\
Comment 1 (Ivan Petrov):\n note"
);
}
#[test]
fn docx_resets_a_comment_range_before_a_notes_part() {
let body = r#"<w:p><w:commentRangeStart w:id="2"/>\
<w:r><w:t>body</w:t></w:r>\
<w:r><w:footnoteReference w:id="1"/></w:r></w:p>"#;
let bytes = package(
body,
&[
(
"word/_rels/document.xml.rels",
&rels(&rel("rId5", "footnotes", "footnotes.xml")),
),
(
"word/footnotes.xml",
r#"<w:footnotes><w:footnote w:id="1">\
<w:p><w:r><w:commentReference w:id="2"/></w:r></w:p></w:footnote></w:footnotes>"#,
),
(
"word/comments.xml",
r#"<w:comments><w:comment w:id="2" w:author="Ivan Petrov">\
<w:p><w:r><w:t>note</w:t></w:r></w:p></w:comment></w:comments>"#,
),
],
);
assert_eq!(
text_of(&bytes),
"[comment 1 starts]body[footnote 1]\n\
Footnote 1:\n [comment 1]\n\
Comment 1 (Ivan Petrov):\n note"
);
}
#[test]
fn docx_names_tracked_changes_and_their_authors() {
let body = r#"<w:p>\
<w:pPr><w:pPrChange w:author="Anna"/></w:pPr>\
<w:r><w:rPr><w:rPrChange w:author="Ivan Petrov"/></w:rPr><w:t>kept</w:t></w:r>\
<w:ins w:author="Ivan Petrov"><w:r><w:t xml:space="preserve"> added</w:t></w:r></w:ins>\
<w:del w:author="Anna"><w:r><w:delText xml:space="preserve"> gone</w:delText></w:r></w:del>\
<w:moveFrom w:author="Anna"><w:r><w:t xml:space="preserve"> away</w:t></w:r></w:moveFrom>\
<w:moveTo w:author="Anna"><w:r><w:t xml:space="preserve"> here</w:t></w:r></w:moveTo>\
<w:ins w:author="Ivan Petrov"/></w:p>"#;
assert_eq!(
text_of(&package(body, &[])),
"Unaccepted tracked changes: 2 insertions, 1 deletion, 1 move, \
2 formatting changes (authors: Anna, Ivan Petrov).\n\n\
[fmt]kept[ins] added[/ins][del] gone[/del]\
[moved-away] away[/moved-away][moved-here] here[/moved-here][fmt ¶]"
);
}
#[test]
fn docx_counts_a_moved_range_once() {
let body = r#"<w:p>\
<w:moveFromRangeStart w:id="1" w:author="Anna" w:name="move1"/>\
<w:moveFrom w:author="Anna"><w:r><w:t>away</w:t></w:r></w:moveFrom>\
<w:moveTo w:author="Anna"><w:r><w:t>here</w:t></w:r></w:moveTo>\
<w:moveToRangeEnd w:id="2"/>\
<w:moveTo w:author="Anna"><w:r><w:t> and there</w:t></w:r></w:moveTo>\
</w:p>"#;
assert!(
text_of(&package(body, &[]))
.starts_with("Unaccepted tracked changes: 1 move (authors: Anna).\n\n")
);
}
#[test]
fn docx_does_not_count_a_move_range_it_never_marks() {
let body = r#"<w:moveFromRangeStart w:id="1" w:author="Anna" w:name="move1"/>\
<w:p><w:r><w:t>Plain</w:t></w:r></w:p>\
<w:moveFromRangeEnd w:id="1"/>"#;
assert_eq!(text_of(&package(body, &[])), "Plain");
}
#[test]
fn docx_marks_the_paragraph_and_section_marks() {
let body = r#"<w:p><w:pPr>\
<w:rPr><w:del w:author="Anna"/></w:rPr>\
<w:pPrChange w:author="Anna"/>\
<w:sectPr><w:sectPrChange w:author="Anna"/></w:sectPr>\
</w:pPr><w:r><w:t>Text</w:t></w:r></w:p>"#;
assert_eq!(
text_of(&package(body, &[])),
"Unaccepted tracked changes: 1 deletion, 2 formatting changes \
(authors: Anna).\n\n\
Text[del ¶][fmt ¶][fmt section]"
);
}
#[test]
fn docx_marks_a_document_level_section_revision() {
let body = concat!(
r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p>"#,
r#"<w:sectPr><w:sectPrChange w:author="Anna"/></w:sectPr>"#,
);
assert_eq!(
text_of(&package(body, &[])),
"Unaccepted tracked changes: 1 formatting change (authors: Anna).\n\n\
Body\n[fmt section]"
);
}
#[test]
fn docx_reads_a_word_text_box_once() {
let body = r#"<w:p><w:r><mc:AlternateContent>\
<mc:Choice Requires="wps"><w:drawing><wps:txbx><w:txbxContent>\
<w:p><w:r><w:t>one</w:t></w:r></w:p><w:p><w:r><w:t>two</w:t></w:r></w:p>\
</w:txbxContent></wps:txbx></w:drawing></mc:Choice>\
<mc:Fallback><w:pict><v:textbox><w:txbxContent>\
<w:p><w:r><w:t>one</w:t></w:r></w:p><w:p><w:r><w:t>two</w:t></w:r></w:p>\
</w:txbxContent></v:textbox></w:pict></mc:Fallback>\
</mc:AlternateContent></w:r><w:r><w:t>after</w:t></w:r></w:p>"#;
assert_eq!(
text_of(&package(body, &[])),
"[text box]\n one\n two\nafter"
);
}
#[test]
fn docx_does_not_tally_a_discarded_alternate_copy() {
let body = r#"<w:p><w:r><mc:AlternateContent>\
<mc:Choice Requires="wps"><w:drawing><wps:txbx><w:txbxContent>\
<w:p><w:ins w:author="Anna"><w:r><w:t>once</w:t></w:r></w:ins></w:p>\
</w:txbxContent></wps:txbx></w:drawing></mc:Choice>\
<mc:Fallback><w:pict><v:textbox><w:txbxContent>\
<w:p><w:ins w:author="Anna"><w:r><w:t>once</w:t></w:r></w:ins></w:p>\
</w:txbxContent></v:textbox></w:pict></mc:Fallback>\
</mc:AlternateContent></w:r></w:p>"#;
assert_eq!(
text_of(&package(body, &[])),
"Unaccepted tracked changes: 1 insertion (authors: Anna).\n\n\
[text box]\n [ins]once[/ins]"
);
}
#[test]
fn docx_does_not_mark_from_a_discarded_copy() {
let body = r#"<w:tbl><w:tblGrid><w:gridCol/></w:tblGrid>
<w:tr><w:tc><w:p><w:r><mc:AlternateContent>\
<mc:Choice Requires="wps"><w:drawing><wps:txbx><w:txbxContent>\
<w:p><w:r><w:t>box</w:t></w:r></w:p></w:txbxContent></wps:txbx></w:drawing></mc:Choice>\
<mc:Fallback><w:pict><v:textbox><w:txbxContent>\
<w:p><w:r><w:t>box</w:t></w:r></w:p></w:txbxContent>\
<w:tblGridChange w:author="QA"/></v:textbox></w:pict></mc:Fallback>\
</mc:AlternateContent></w:r></w:p></w:tc></w:tr>
</w:tbl>"#;
assert_eq!(
text_of(&package(body, &[])),
"Table 1: 1 row, 1 column\n R1: C1: [text box] box"
);
}
#[test]
fn docx_does_not_tally_a_copy_that_rendered_nothing() {
let body = r#"<w:p><w:r><mc:AlternateContent>\
<mc:Choice Requires="wps"><w:ins w:author="Anna"/></mc:Choice>\
<mc:Choice Requires="wpg"><w:drawing><wps:txbx><w:txbxContent>\
<w:p><w:ins w:author="Anna"><w:r><w:t>once</w:t></w:r></w:ins></w:p>\
</w:txbxContent></wps:txbx></w:drawing></mc:Choice>\
</mc:AlternateContent></w:r></w:p>"#;
assert_eq!(
text_of(&package(body, &[])),
"Unaccepted tracked changes: 1 insertion (authors: Anna).\n\n\
[text box]\n [ins]once[/ins]"
);
}
#[test]
fn docx_reads_a_header_text_box_once() {
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p>
<w:sectPr><w:headerReference w:type="default" r:id="rId1"/></w:sectPr>"#;
let choice = r#"<mc:Choice Requires="wps"><w:drawing><wps:txbx><w:txbxContent>\
<w:p><w:r><w:t>box</w:t></w:r></w:p></w:txbxContent></wps:txbx></w:drawing></mc:Choice>"#;
let fallback = r"<mc:Fallback><w:pict><v:textbox><w:txbxContent>\
<w:p><w:r><w:t>box</w:t></w:r></w:p></w:txbxContent></v:textbox></w:pict></mc:Fallback>";
let header = format!(
"<w:hdr><w:p><w:r><mc:AlternateContent>{choice}{fallback}</mc:AlternateContent></w:r></w:p></w:hdr>"
);
let bytes = package(
body,
&[
(
"word/_rels/document.xml.rels",
&rels(&rel("rId1", "header", "header1.xml")),
),
("word/header1.xml", &header),
],
);
assert_eq!(
text_of(&bytes),
"Body\nHeader (default):\n [text box]\n box"
);
}
#[test]
fn docx_falls_back_when_a_choice_renders_nothing() {
let body = r#"<w:p><mc:AlternateContent>\
<mc:Choice Requires="wps"><w:drawing/></mc:Choice>\
<mc:Fallback><w:pict><v:textbox><w:txbxContent><w:p><w:r><w:t>vml</w:t></w:r></w:p>\
</w:txbxContent></v:textbox></w:pict></mc:Fallback>\
</mc:AlternateContent></w:p>"#;
assert_eq!(text_of(&package(body, &[])), "[text box]\n vml");
}
#[test]
fn docx_reads_a_later_choice_that_has_content() {
let body = r#"<w:p><mc:AlternateContent>\
<mc:Choice Requires="wps"><w:drawing/></mc:Choice>\
<mc:Choice Requires="wpg"><w:drawing><wps:txbx><w:txbxContent>\
<w:p><w:r><w:t>second</w:t></w:r></w:p></w:txbxContent></wps:txbx></w:drawing></mc:Choice>\
<mc:Fallback><w:pict><v:textbox><w:txbxContent>\
<w:p><w:r><w:t>vml</w:t></w:r></w:p></w:txbxContent></v:textbox></w:pict></mc:Fallback>\
</mc:AlternateContent></w:p>"#;
assert_eq!(text_of(&package(body, &[])), "[text box]\n second");
}
#[test]
fn docx_reports_field_instructions_and_their_results() {
let body = r#"<w:p>\
<w:r><w:fldChar w:fldCharType="begin"/></w:r>\
<w:r><w:instrText xml:space="preserve"> PAGE \</w:instrText></w:r>\
<w:r><w:instrText xml:space="preserve">* MERGEFORMAT </w:instrText></w:r>\
<w:r><w:fldChar w:fldCharType="separate"/></w:r>\
<w:r><w:t>7</w:t></w:r>\
<w:r><w:fldChar w:fldCharType="end"/></w:r>\
<w:r><w:fldChar w:fldCharType="begin"/></w:r>\
<w:r><w:instrText>DATE</w:instrText></w:r>\
<w:r><w:fldChar w:fldCharType="end"/></w:r>\
</w:p>"#;
assert_eq!(
text_of(&package(body, &[])),
"[field PAGE \\* MERGEFORMAT]7[field DATE]"
);
}
#[test]
fn docx_caps_a_field_instruction_without_a_trailing_space() {
let instruction = format!("{} bcd", "a".repeat(MAX_FIELD_INSTRUCTION_CHARS - 1));
let body = format!(
r#"<w:p><w:r><w:fldSimple w:instr="{instruction}"><w:r><w:t>x</w:t></w:r></w:fldSimple></w:r></w:p>"#
);
assert_eq!(
text_of(&package(&body, &[])),
format!("[field {}]x", "a".repeat(MAX_FIELD_INSTRUCTION_CHARS - 1))
);
}
#[test]
fn docx_reports_a_field_a_part_left_open() {
let body = r#"<w:p><w:r><w:t xml:space="preserve">Before </w:t></w:r>\
<w:r><w:fldChar w:fldCharType="begin"/></w:r>\
<w:r><w:instrText>PAGE</w:instrText></w:r>\
<w:r><w:t>After</w:t></w:r></w:p>"#;
assert_eq!(text_of(&package(body, &[])), "Before \n[field PAGE]After");
}
#[test]
fn docx_reports_an_unparsable_body_as_unreadable() {
let bytes = zip_fixture(&[("word/document.xml", b"<w:document><w:body><w:p></w:body>")]);
let dir = tempfile::tempdir().expect("tempdir");
assert!(matches!(
convert_docx(&bytes, dir.path()),
DocOutcome::Unreadable { .. }
));
}
#[test]
fn docx_skips_a_notes_part_it_cannot_parse() {
let body =
r#"<w:p><w:r><w:t>Body</w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r></w:p>"#;
let bytes = package(
body,
&[
(
"word/_rels/document.xml.rels",
&rels(&rel("rId5", "footnotes", "footnotes.xml")),
),
("word/footnotes.xml", "<w:footnotes><w:footnote"),
],
);
assert_eq!(text_of(&bytes), "Body[footnote ?]");
}
#[test]
fn every_docx_mark_the_shared_list_names_is_a_mark_the_reader_prints() {
let revised = concat!(
r#"<w:p><w:pPr><w:pPrChange w:author="Anna"/>"#,
r#"<w:sectPr><w:sectPrChange w:author="Anna"/></w:sectPr></w:pPr>"#,
r#"<w:commentRangeStart w:id="2"/>"#,
r#"<w:r><w:rPr><w:rPrChange w:author="Anna"/></w:rPr><w:t>kept</w:t></w:r>"#,
r#"<w:ins w:author="Anna"><w:r><w:t xml:space="preserve"> added</w:t></w:r></w:ins>"#,
r#"<w:del w:author="Anna"><w:r><w:delText xml:space="preserve"> gone</w:delText></w:r></w:del>"#,
r#"<w:moveFrom w:author="Anna"><w:r><w:t xml:space="preserve"> away</w:t></w:r></w:moveFrom>"#,
r#"<w:moveTo w:author="Anna"><w:r><w:t xml:space="preserve"> here</w:t></w:r></w:moveTo>"#,
r#"<w:r><w:fldSimple w:instr="PAGE \* MERGEFORMAT"><w:t>1</w:t></w:fldSimple></w:r>"#,
r#"<w:r><w:pict><v:shape><v:textbox><w:txbxContent><w:p><w:r><w:t>boxed</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r>"#,
r#"</w:p>"#,
r#"<w:sectPr><w:headerReference w:type="first" r:id="rId1"/><w:headerReference w:type="default" r:id="rId2"/></w:sectPr>"#,
);
let revised = package(
revised,
&[
(
"word/_rels/document.xml.rels",
&rels(&format!(
"{}{}{}",
rel("rId1", "header", "header1.xml"),
rel("rId2", "header", "header2.xml"),
rel("rId5", "footnotes", "footnotes.xml"),
)),
),
(
"word/header1.xml",
r"<w:hdr><w:p><w:r><w:t>FirstHead</w:t></w:r></w:p></w:hdr>",
),
(
"word/header2.xml",
r"<w:hdr><w:p><w:r><w:t>DefaultHead</w:t></w:r></w:p></w:hdr>",
),
(
"word/footnotes.xml",
r#"<w:footnotes><w:footnote w:id="1"><w:p><w:r><w:t>orphan note</w:t></w:r></w:p></w:footnote></w:footnotes>"#,
),
(
"word/comments.xml",
r#"<w:comments><w:comment w:id="2" w:author="Ivan Petrov"><w:p><w:r><w:t>needs a citation</w:t></w:r></w:p></w:comment></w:comments>"#,
),
],
);
let paragraph_marks = r#"<w:p><w:pPr><w:rPr><w:ins w:author="Anna"/><w:del w:author="Anna"/><w:moveTo w:author="Anna"/><w:moveFrom w:author="Anna"/></w:rPr></w:pPr><w:r><w:t>P</w:t></w:r></w:p>"#;
let tables = concat!(
r#"<w:tbl><w:tblPr><w:tblPrChange w:author="Anna"/></w:tblPr>"#,
r#"<w:tblGrid><w:gridCol/><w:gridCol/><w:gridCol/><w:gridCol/></w:tblGrid>"#,
r#"<w:tr><w:tc><w:p><w:r><w:t>One</w:t></w:r></w:p></w:tc>"#,
r#"<w:tc><w:tcPr><w:gridSpan w:val="3"/></w:tcPr><w:p><w:r><w:t>Wide</w:t></w:r></w:p></w:tc></w:tr>"#,
r#"<w:tr><w:tc><w:tcPr><w:cellMerge/></w:tcPr><w:p><w:r><w:t>M</w:t></w:r></w:p></w:tc>"#,
r#"<w:tc><w:p><w:r><w:t>Two</w:t></w:r></w:p></w:tc>"#,
r#"<w:tc><w:p><w:r><w:t>Three</w:t></w:r></w:p></w:tc>"#,
r#"<w:tc><w:p><w:r><w:t>Four</w:t></w:r></w:p></w:tc></w:tr>"#,
r#"<w:tr><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc>"#,
r#"<w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>Origin</w:t></w:r></w:p></w:tc>"#,
r#"<w:tc><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc>"#,
r#"<w:tc><w:p><w:r><w:t>D</w:t></w:r></w:p></w:tc></w:tr>"#,
r#"<w:tr><w:tc><w:p><w:r><w:t>E</w:t></w:r></w:p></w:tc>"#,
r#"<w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc>"#,
r#"<w:tc><w:p><w:r><w:t>G</w:t></w:r></w:p></w:tc>"#,
r#"<w:tc><w:p><w:r><w:t>H</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"#,
);
let corpus = format!(
"{}\n{}\n{}",
text_of(&revised),
text_of(&package(paragraph_marks, &[])),
text_of(&package(tables, &[])),
);
let marks = crate::docgen::docx_marks();
assert!(!marks.is_empty(), "the shared list of docx marks is empty");
for mark in marks {
let example = mark.example.as_str();
assert!(
corpus.contains(example),
"the reader never prints {example:?} (entry {})",
mark.pattern
);
}
}
fn mark_literals(source: &str) -> Vec<String> {
let literal = regex::Regex::new(r#""((?:[^"\\]|\\.)*)""#).expect("the literal pattern");
literal
.captures_iter(source)
.map(|found| found[1].to_owned())
.filter(|text| text.contains(['[', ']', '(', ')', '¶']))
.collect()
}
#[test]
fn every_docx_mark_the_reader_writes_is_in_the_shared_list() {
fn words_in(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_ascii_alphabetic())
.filter(|word| word.len() >= 3)
.map(str::to_owned)
.collect()
}
let marks = crate::docgen::docx_marks();
let patterns: Vec<regex::Regex> = marks
.iter()
.map(|mark| regex::Regex::new(&mark.pattern).expect("a docx mark's pattern is a regex"))
.collect();
let words: Vec<String> = marks
.iter()
.flat_map(|mark| words_in(&format!("{} {}", mark.pattern, mark.example)))
.collect();
let spelled = |word: &str| {
words
.iter()
.any(|known| known == word || known.strip_suffix('s') == Some(word))
};
let placeholder = regex::Regex::new(r"\{[^{}]*\}").expect("the placeholder pattern");
let code = include_str!("docx.rs")
.split("#[cfg(test)]")
.next()
.expect("the reader's own code precedes its tests")
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
let candidates = mark_literals(&code);
assert!(
candidates.len() > 10,
"the scan must see this reader's marks, saw {}",
candidates.len()
);
for text in &candidates {
let written = placeholder.replace_all(text, " ");
if patterns.iter().any(|pattern| pattern.is_match(&written)) {
continue;
}
for word in words_in(&written) {
assert!(
spelled(&word),
"{text:?} is a literal this reader writes with the word {word:?}, which no \
entry of the shared list spells — a mark a reading prints belongs in \
assets/docgen/rules.json's docx_marks"
);
}
}
}
#[test]
fn the_mark_scan_leaves_a_sentence_of_prose_alone() {
let prose = r#"
let a = "text is not in the body";
let b = "Comment range is unclosed";
let c = "part of the document";
let d = "the field instruction is reported up to this many characters";
"#;
assert!(
mark_literals(prose).is_empty(),
"prose read as a mark: {:?}",
mark_literals(prose)
);
let marks = r#"
let e = format!("[revision {}]", n);
let f = "(revision started)";
let g = r"[revision note]";
"#;
assert_eq!(
mark_literals(marks),
[
r"[revision {}]".to_owned(),
"(revision started)".to_owned(),
"[revision note]".to_owned(),
]
);
}
}