#![cfg(feature = "docx")]
use std::io::Cursor;
use docx_rs::{
read_docx_with_options, DocumentChild, Docx, InsertChild, MoveToChild, Paragraph,
ParagraphChild, ReadDocxOptions, Run, RunChild, StructuredDataTag, StructuredDataTagChild,
Table, TableCellContent, TableChild, TableRowChild,
};
use crate::error::{Error, Result};
use crate::tools::workspace::{Workspace, Wrote};
fn unreadable(rel: &str, e: impl std::fmt::Display) -> Error {
Error::Config(format!("{rel} is not a readable .docx document: {e}"))
}
fn unwritable(rel: &str, e: impl std::fmt::Display) -> Error {
Error::Config(format!("cannot build the .docx for {rel}: {e}"))
}
fn push_run(out: &mut String, run: &Run) {
for child in &run.children {
match child {
RunChild::Text(t) => out.push_str(&t.text),
RunChild::Tab(_) | RunChild::PTab(_) => out.push('\t'),
RunChild::Break(_) | RunChild::CarriageReturn(_) => out.push('\n'),
_ => {}
}
}
}
fn push_inline(out: &mut String, children: &[ParagraphChild]) {
for child in children {
match child {
ParagraphChild::Run(r) => push_run(out, r),
ParagraphChild::Hyperlink(h) => push_inline(out, &h.children),
ParagraphChild::Insert(i) => {
for c in &i.children {
if let InsertChild::Run(r) = c {
push_run(out, r);
}
}
}
ParagraphChild::MoveTo(m) => {
for c in &m.children {
if let MoveToChild::Run(r) = c {
push_run(out, r);
}
}
}
_ => {}
}
}
}
fn push_paragraph(lines: &mut Vec<String>, p: &Paragraph) {
let mut line = String::new();
push_inline(&mut line, &p.children);
lines.push(line);
}
fn push_table(lines: &mut Vec<String>, table: &Table) {
for TableChild::TableRow(row) in &table.rows {
let mut cells = Vec::new();
for TableRowChild::TableCell(cell) in &row.cells {
let mut inner = Vec::new();
for content in &cell.children {
match content {
TableCellContent::Paragraph(p) => push_paragraph(&mut inner, p),
TableCellContent::Table(t) => push_table(&mut inner, t),
TableCellContent::StructuredDataTag(s) => push_sdt(&mut inner, s),
_ => {}
}
}
cells.push(inner.join(" "));
}
lines.push(cells.join("\t"));
}
}
fn push_sdt(lines: &mut Vec<String>, tag: &StructuredDataTag) {
for child in &tag.children {
match child {
StructuredDataTagChild::Run(r) => {
let mut line = String::new();
push_run(&mut line, r);
lines.push(line);
}
StructuredDataTagChild::Paragraph(p) => push_paragraph(lines, p),
StructuredDataTagChild::Table(t) => push_table(lines, t),
StructuredDataTagChild::StructuredDataTag(s) => push_sdt(lines, s),
_ => {}
}
}
}
pub fn read_text(ws: &Workspace, rel: &str) -> Result<String> {
let bytes = ws.read_bytes(rel)?;
let doc = read_docx_with_options(
&bytes,
ReadDocxOptions::default().with_image_previews(false),
)
.map_err(|e| unreadable(rel, e))?;
let mut lines = Vec::new();
for child in &doc.document.children {
match child {
DocumentChild::Paragraph(p) => push_paragraph(&mut lines, p),
DocumentChild::Table(t) => push_table(&mut lines, t),
DocumentChild::StructuredDataTag(s) => push_sdt(&mut lines, s),
_ => {}
}
}
Ok(lines.join("\n"))
}
pub fn write_new(ws: &Workspace, rel: &str, paragraphs: &[String]) -> Result<Wrote> {
let mut doc = Docx::new();
for text in paragraphs {
doc = doc.add_paragraph(Paragraph::new().add_run(Run::new().add_text(text)));
}
let mut buf = Cursor::new(Vec::new());
doc.pack(&mut buf).map_err(|e| unwritable(rel, e))?;
ws.write_bytes(rel, &buf.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::Policy;
fn dir() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
fn guarded(root: &std::path::Path) -> Workspace {
Workspace::with_policy(
root,
Policy::default()
.layer("base")
.allow_read("*")
.allow_write("*")
.deny_read("secrets/*")
.deny_write("secrets/*"),
)
}
fn sample() -> Vec<String> {
vec![
"Quarterly review".to_string(),
"Revenue rose 4% — see <appendix> & the notes".to_string(),
String::new(),
"Signed, EMEA".to_string(),
]
}
#[test]
fn write_new_then_read_text_round_trips_the_paragraphs() {
let d = dir();
let ws = Workspace::new(d.path());
let paragraphs = sample();
assert_eq!(
write_new(&ws, "out/report.docx", ¶graphs).unwrap(),
Wrote::Created
);
let text = read_text(&ws, "out/report.docx").unwrap();
assert_eq!(
text,
paragraphs.join("\n"),
"one line per paragraph, in order, escaping intact"
);
}
#[test]
fn a_file_that_is_not_a_document_is_an_error_not_a_panic() {
let d = dir();
let ws = Workspace::new(d.path());
ws.write_bytes("notes.docx", b"this is plainly not a zip archive")
.unwrap();
ws.write_bytes(
"empty.docx",
&[
b'P', b'K', 5, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
],
)
.unwrap();
for rel in ["notes.docx", "empty.docx"] {
let shown = read_text(&ws, rel).unwrap_err().to_string();
assert!(
shown.contains(rel) && shown.contains("not a readable"),
"the message names the file and what is wrong with it, got {shown}"
);
}
}
#[test]
fn a_denied_path_is_refused_for_both_reading_and_writing() {
let d = dir();
write_new(&Workspace::new(d.path()), "secrets/report.docx", &sample()).unwrap();
let ws = guarded(d.path());
let read = read_text(&ws, "secrets/report.docx");
assert!(
matches!(&read, Err(Error::Refused { act, target, .. })
if act == "read" && target == "secrets/report.docx"),
"got {read:?}"
);
let write = write_new(&ws, "secrets/report.docx", &sample());
assert!(
matches!(&write, Err(Error::Refused { act, target, .. })
if act == "write" && target == "secrets/report.docx"),
"got {write:?}"
);
}
#[test]
fn the_same_operations_succeed_on_a_path_the_policy_allows() {
let d = dir();
let ws = guarded(d.path());
assert_eq!(
write_new(&ws, "open/report.docx", &sample()).unwrap(),
Wrote::Created
);
assert!(read_text(&ws, "open/report.docx")
.unwrap()
.contains("Quarterly review"));
}
}